pi-cc-extensions 0.9.4 → 0.9.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.
package/README.en.md CHANGED
@@ -50,7 +50,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
50
50
 
51
51
  // features
52
52
  "enableSessionReference": true, // @ session references
53
- "enableSubagentAutocomplete": true, // @ subagent completion and delegation hints
53
+ "enableSubagentAutocomplete": true, // @subagent:[name] completion and delegation hints
54
54
  "enableContextCommand": true, // /context usage check
55
55
  "enableAgentSummary": true, // per-turn tool summary
56
56
  "enableWorkingMessage": true, // Working... bottom token/elapsed
@@ -59,7 +59,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
59
59
  // ui
60
60
  "expandedInputMaxLines": 5, // expanded tool Input lines; overflow shows a footer hint
61
61
  "expandedOutputMaxLines": 10, // expanded tool Output lines; overflow shows a footer hint
62
- "expandedPreviewMaxLines": 40, // max lines for expanded diff/TaskList bodies
62
+ "expandedPreviewMaxLines": 40, // max lines for expanded TaskList bodies (expanded diffs always show all)
63
63
  "inputClip": 100, // tool summary path/command clip length
64
64
  "showStartupHeader": true, // startup header (logo + tips) toggle
65
65
  "scrollStepLines": 3, // fullscreen wheel scroll step
@@ -89,7 +89,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
89
89
  ```
90
90
 
91
91
  > [!TIP]
92
- > **Fullscreen**: click `click to show more` to expand tool cards, thinking, Skill, and compact summaries. When expanded Input/Output exceeds the line cap, the footer `… +N more lines • click to show more` opens a full preview. Double-click an expanded panel to collapse it.
92
+ > **Fullscreen**: click `click to show more` to expand tool cards, thinking, Skill, and compact summaries. An expanded diff always shows every line; when expanded Input/Output exceeds the line cap, the footer `… +N more lines • click to show more` opens a full preview. Double-click an expanded panel to collapse it.
93
93
 
94
94
  > [!NOTE]
95
95
  > **Mermaid rendering**: set `markdown.mermaid` to `final` via `~/.pi/agent/settings.json` or the Mermaid diagrams option in `/settings`. Default `streaming` redraws per frame; `final` renders once at completion.
package/README.md CHANGED
@@ -50,7 +50,7 @@ pi install git:github.com/minuque/pi-cc-extensions
50
50
 
51
51
  // features
52
52
  "enableSessionReference": true, // @ session 引用
53
- "enableSubagentAutocomplete": true, // @ subagent 补全与委派提示
53
+ "enableSubagentAutocomplete": true, // @subagent:[name] 补全与委派提示
54
54
  "enableContextCommand": true, // /context 上下文检查
55
55
  "enableAgentSummary": true, // 每回合工具摘要
56
56
  "enableWorkingMessage": true, // Working... 底部 token/耗时
@@ -59,7 +59,7 @@ pi install git:github.com/minuque/pi-cc-extensions
59
59
  // ui
60
60
  "expandedInputMaxLines": 5, // 展开工具卡 Input 可见行数,超出在末行显示展开提示
61
61
  "expandedOutputMaxLines": 10, // 展开工具卡 Output 可见行数,超出在末行显示展开提示
62
- "expandedPreviewMaxLines": 40, // 展开 diff/TaskList 正文最大行数
62
+ "expandedPreviewMaxLines": 40, // 展开 TaskList 正文最大行数(展开 diff 始终全量)
63
63
  "inputClip": 100, // 工具摘要 path/command 折叠字符数
64
64
  "showStartupHeader": true, // 启动头(logo + tips)开关
65
65
  "scrollStepLines": 3, // fullscreen 滚轮步进
@@ -89,7 +89,7 @@ pi install git:github.com/minuque/pi-cc-extensions
89
89
  ```
90
90
 
91
91
  > [!TIP]
92
- > **全屏模式**:单击 `click to show more` 展开工具卡、思考、Skill 和 compact 摘要;展开后 Input/Output 超行时,末行 `… +N more lines • click to show more` 打开全量预览,双击展开面板收起。
92
+ > **全屏模式**:单击 `click to show more` 展开工具卡、思考、Skill 和 compact 摘要;工具卡展开态 diff 全量显示,Input/Output 超行时末行 `… +N more lines • click to show more` 打开全量预览,双击展开面板收起。
93
93
 
94
94
  > [!NOTE]
95
95
  > **Mermaid 渲染**:建议把 `markdown.mermaid` 设为 `final`(`~/.pi/agent/settings.json` 或 `/settings` 面板的 Mermaid diagrams 选项)。默认 `streaming` 逐帧重绘,`final` 渲染最终版更稳定。
@@ -37,9 +37,8 @@ export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
37
37
  writeDiffCollapsedLines: 0,
38
38
  diffWordWrap: true,
39
39
  /**
40
- * Expanded tool/diff body cap. 40 ≈ one screen of content after title,
41
- * Input section, editor, and status — keeps the TUI compact.
42
- * Raise via /ccstyle → Diff → Expanded max lines when reviewing large dumps.
40
+ * Expanded TaskList body cap. Tool Input/Output and diffs use their own settings;
41
+ * an expanded diff always renders every line.
43
42
  */
44
43
  expandedPreviewMaxLines: 40,
45
44
  };
@@ -485,8 +485,7 @@ export async function showCcstylePanel(
485
485
  const expandedMaxSetting = {
486
486
  id: "expandedPreviewMaxLines",
487
487
  label: "Expanded max lines",
488
- description:
489
- "Max diff/TaskList body lines when expanded. Tool Input/Output use the two settings above.",
488
+ description: "Max TaskList body lines when expanded. Expanded diffs show every line.",
490
489
  currentValue: String(config.expandedPreviewMaxLines),
491
490
  values: [...EXPANDED_PREVIEW_MAX_LINES_VALUES],
492
491
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
@@ -556,7 +555,7 @@ export async function showCcstylePanel(
556
555
  const subagentAutocompleteToggle = featureToggleSetting(
557
556
  "enableSubagentAutocomplete",
558
557
  "Subagent autocomplete",
559
- "@ subagent mentions suggest agents and inject delegation instructions. Next restart applies.",
558
+ "@subagent:[name] mentions suggest agents and inject delegation instructions. Next restart applies.",
560
559
  "Subagent autocomplete disabled.",
561
560
  config.enableSubagentAutocomplete,
562
561
  );
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  SESSION_REFERENCE_CUSTOM_TYPE,
16
16
  SESSION_REFERENCE_PREFIX,
17
+ assignReferenceTokens,
17
18
  buildReferenceContentFromSections,
18
19
  extractSessionReferenceIds,
19
20
  formatReferenceSession,
@@ -123,7 +124,7 @@ function formatDate(date: Date): string {
123
124
  const sessionSearchTextCache = new WeakMap<SessionReference, string>();
124
125
  const sessionItemCache = new WeakMap<
125
126
  SessionReference,
126
- { cwd: string; named?: AutocompleteItem; stable?: AutocompleteItem }
127
+ { cwd: string; token: string; item: AutocompleteItem }
127
128
  >();
128
129
 
129
130
  function sessionSearchText(reference: SessionReference): string {
@@ -138,30 +139,24 @@ function sessionSearchText(reference: SessionReference): string {
138
139
  function sessionItem(
139
140
  reference: SessionReference,
140
141
  currentCwd: string,
141
- useStableId: boolean,
142
+ token: string,
142
143
  ): AutocompleteItem {
143
- let cached = sessionItemCache.get(reference);
144
- const variant = useStableId ? "stable" : "named";
145
- if (cached?.cwd === currentCwd && cached[variant]) return cached[variant];
144
+ const cached = sessionItemCache.get(reference);
145
+ if (cached?.cwd === currentCwd && cached.token === token) return cached.item;
146
146
 
147
147
  const session = reference.info;
148
148
  const workspace = samePath(session.cwd, currentCwd)
149
149
  ? "current workspace"
150
150
  : session.cwd || "unknown workspace";
151
- const label = reference.kind === "subagent" ? "[SubAgent]" : "[Session]";
152
- // 唯一名称保持短格式;同名或无名称时使用稳定 ID,避免引用歧义。
153
- const sessionName = session.name?.trim();
154
- const referenceId = useStableId
155
- ? reference.referenceIds[0]
156
- : (sessionName ?? reference.referenceIds[0]);
151
+ const kindLabel = reference.kind === "subagent" ? "[SubAgent]" : "[Session]";
152
+ // 消歧后的 token 会带上日期;回落到 ID 时下拉仍显示可读标题。
153
+ const title = token === (reference.referenceIds[0] ?? session.id) ? sessionTitle(session) : token;
157
154
  const item: AutocompleteItem = {
158
- value: `${SESSION_REFERENCE_PREFIX}[${referenceId}]`,
159
- label: `${label} ${sessionTitle(session)}`,
155
+ value: `${SESSION_REFERENCE_PREFIX}[${token}]`,
156
+ label: `${kindLabel} ${title}`,
160
157
  description: `${workspace} · ${session.messageCount} messages · ${formatDate(session.modified)}`,
161
158
  };
162
- if (cached?.cwd !== currentCwd) cached = { cwd: currentCwd };
163
- cached[variant] = item;
164
- sessionItemCache.set(reference, cached);
159
+ sessionItemCache.set(reference, { cwd: currentCwd, token, item });
165
160
  return item;
166
161
  }
167
162
 
@@ -197,11 +192,7 @@ function filterSessions(
197
192
  if (query.startsWith("session:")) return [];
198
193
 
199
194
  const ordered = orderSessionReferences(references, currentCwd);
200
- const nameCounts = new Map<string, number>();
201
- for (const reference of ordered) {
202
- const name = reference.info.name?.trim();
203
- if (name) nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
204
- }
195
+ const tokens = assignReferenceTokens(ordered);
205
196
  const trimmed = query.trim();
206
197
  // 模糊匹配只接受有 session name 的会话;subagent 按 agent name 匹配。
207
198
  const searchable = ordered.filter(
@@ -211,10 +202,11 @@ function filterSessions(
211
202
  const matches = trimmed
212
203
  ? fuzzyFilter(searchable, trimmed, sessionSearchText)
213
204
  : ordered.slice(0, MAX_SESSION_SUGGESTIONS);
214
- return matches.slice(0, MAX_SESSION_SUGGESTIONS).map((reference) => {
215
- const name = reference.info.name?.trim();
216
- return sessionItem(reference, currentCwd, Boolean(name && (nameCounts.get(name) ?? 0) > 1));
217
- });
205
+ return matches
206
+ .slice(0, MAX_SESSION_SUGGESTIONS)
207
+ .map((reference) =>
208
+ sessionItem(reference, currentCwd, tokens.get(reference) ?? reference.info.id),
209
+ );
218
210
  }
219
211
 
220
212
  function isPathLikeQuery(query: string): boolean {
@@ -452,22 +444,17 @@ export default function sessionReferenceExtension(pi: ExtensionAPI): void {
452
444
  ),
453
445
  ));
454
446
  const referencesById = new Map<string, SessionReference>();
455
- const referencesByName = new Map<string, SessionReference>();
456
- const ambiguousNames = new Set<string>();
447
+ const referencesByToken = new Map<string, SessionReference>();
457
448
  for (const reference of references) {
458
449
  for (const id of reference.referenceIds) referencesById.set(id, reference);
459
- const name = reference.info.name?.trim();
460
- if (!name || ambiguousNames.has(name)) continue;
461
- if (referencesByName.has(name)) {
462
- referencesByName.delete(name);
463
- ambiguousNames.add(name);
464
- } else {
465
- referencesByName.set(name, reference);
466
- }
450
+ }
451
+ // 同位重名的会话用带日期的 token 区分,解析端与补全端用同一套分配。
452
+ for (const [reference, token] of assignReferenceTokens(references)) {
453
+ referencesByToken.set(token, reference);
467
454
  }
468
455
  const seenReferences = new Set<SessionReference>();
469
456
  const matchingReferences = referenceIds
470
- .map((id) => referencesById.get(id) ?? referencesByName.get(id))
457
+ .map((id) => referencesById.get(id) ?? referencesByToken.get(id))
471
458
  .filter((reference): reference is SessionReference => {
472
459
  if (!reference || reference.info.id === currentSessionId || seenReferences.has(reference))
473
460
  return false;
@@ -1,3 +1,6 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { stripTerminalSequences } from "../../utils/ansi-text.ts";
3
+
1
4
  export const SESSION_REFERENCE_CUSTOM_TYPE = "session-reference";
2
5
  export const SESSION_REFERENCE_PREFIX = "@session:";
3
6
 
@@ -45,13 +48,104 @@ export function extractSessionReferenceIds(text: string): string[] {
45
48
  return ids;
46
49
  }
47
50
 
51
+ const EMPTY_SESSION_TITLE = "(no messages)";
52
+
48
53
  export function sessionTitle(info: ReferenceSessionInfo, maxLength = 80): string {
49
- const source = info.name?.trim() || info.firstMessage || "(no messages)";
54
+ const source = info.name?.trim() || info.firstMessage || EMPTY_SESSION_TITLE;
50
55
  const normalized = source.replace(CONTROL_CHARACTERS, " ").replace(/\s+/g, " ").trim();
51
56
  if (normalized.length <= maxLength) return normalized;
52
57
  return `${normalized.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
53
58
  }
54
59
 
60
+ /**
61
+ * 编辑器里的 `@session:[…]` 是行内文本,token 按显示宽度收口,
62
+ * 不让首条消息整段挤进输入行。同名会话靠后缀消歧,不受它影响。
63
+ */
64
+ export const REFERENCE_TOKEN_MAX_WIDTH = 32;
65
+
66
+ /** Editor `@session:[…]` text: the readable title, never a placeholder or brackets. */
67
+ export function sessionReferenceLabel(
68
+ info: ReferenceSessionInfo,
69
+ maxWidth = REFERENCE_TOKEN_MAX_WIDTH,
70
+ ): string {
71
+ const title = sessionTitle(info).replace(/[[\]]/g, "").trim();
72
+ if (!title || title === EMPTY_SESSION_TITLE) return "";
73
+ // truncateToWidth 会在省略号前后补 ANSI reset,token 是明文,去掉。
74
+ return stripTerminalSequences(truncateToWidth(title, maxWidth, "…")).trim();
75
+ }
76
+
77
+ export interface ReferenceTokenSource {
78
+ referenceIds: string[];
79
+ info: ReferenceSessionInfo;
80
+ }
81
+
82
+ /** UTC keeps the disambiguator identical across timezones (same-day drift is harmless). */
83
+ function referenceTokenDate(date: Date): string {
84
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
85
+ const day = String(date.getUTCDate()).padStart(2, "0");
86
+ return `${month}-${day}`;
87
+ }
88
+
89
+ function referenceTokenTime(date: Date): string {
90
+ const hours = String(date.getUTCHours()).padStart(2, "0");
91
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
92
+ return `${hours}:${minutes}`;
93
+ }
94
+
95
+ /** Title first; date then time widen the disambiguation before the ID fallback. */
96
+ function referenceTokenCandidates(reference: ReferenceTokenSource): string[] {
97
+ const title = sessionReferenceLabel(reference.info);
98
+ const stableId = reference.referenceIds[0] ?? reference.info.id;
99
+ if (!title) return [stableId];
100
+ const date = referenceTokenDate(reference.info.modified);
101
+ return [
102
+ title,
103
+ `${title} ${date}`,
104
+ `${title} ${date} ${referenceTokenTime(reference.info.modified)}`,
105
+ stableId,
106
+ ];
107
+ }
108
+
109
+ /**
110
+ * Assign each reference a unique editor token. Same-named sessions keep a readable
111
+ * title plus a date/time suffix; the stable ID is only the last resort.
112
+ */
113
+ export function assignReferenceTokens<T extends ReferenceTokenSource>(
114
+ references: T[],
115
+ ): Map<T, string> {
116
+ const candidates = new Map<T, string[]>();
117
+ let levels = 0;
118
+ for (const reference of references) {
119
+ const options = referenceTokenCandidates(reference);
120
+ levels = Math.max(levels, options.length);
121
+ candidates.set(reference, options);
122
+ }
123
+
124
+ const tokens = new Map<T, string>();
125
+ const taken = new Set<string>();
126
+ for (let level = 0; level < levels && tokens.size < references.length; level++) {
127
+ const counts = new Map<string, number>();
128
+ for (const [reference, options] of candidates) {
129
+ const candidate = tokens.has(reference) ? undefined : options[level];
130
+ if (candidate) counts.set(candidate, (counts.get(candidate) ?? 0) + 1);
131
+ }
132
+ for (const [reference, options] of candidates) {
133
+ if (tokens.has(reference)) continue;
134
+ const candidate = options[level];
135
+ if (candidate && counts.get(candidate) === 1 && !taken.has(candidate)) {
136
+ tokens.set(reference, candidate);
137
+ taken.add(candidate);
138
+ }
139
+ }
140
+ }
141
+ for (const reference of references) {
142
+ if (!tokens.has(reference)) {
143
+ tokens.set(reference, reference.referenceIds[0] ?? reference.info.id);
144
+ }
145
+ }
146
+ return tokens;
147
+ }
148
+
55
149
  function byteLength(text: string): number {
56
150
  return Buffer.byteLength(text, "utf8");
57
151
  }
@@ -18,9 +18,12 @@ type AgentInfo = {
18
18
  filePath: string;
19
19
  };
20
20
 
21
+ export const SUBAGENT_DELEGATION_CUSTOM_TYPE = "subagent-delegation";
22
+ export const SUBAGENT_REFERENCE_PREFIX = "@subagent:";
23
+
21
24
  const MAX_SUGGESTIONS = 2;
22
- // Match `@name` but NOT `@session:` (reserved by session-reference extension).
23
- const AGENT_NAME_PATTERN = /(?:^|[\t ])@((?:[^\s:@][^\s:]*)?)$/;
25
+ const MENTION_PATTERN = /(?:^|[\t ])@([^\s@]*)$/;
26
+ const SUBAGENT_REFERENCE_PATTERN = /(?:^|\s)@subagent:\[([^\]]+)](?![\w-])/g;
24
27
 
25
28
  function getAgentDir(): string {
26
29
  const override = process.env.PI_CODING_AGENT_DIR;
@@ -60,6 +63,36 @@ function loadAgents(): AgentInfo[] {
60
63
  });
61
64
  }
62
65
 
66
+ function extractMentionQuery(textBeforeCursor: string): string | undefined {
67
+ return textBeforeCursor.match(MENTION_PATTERN)?.[1];
68
+ }
69
+
70
+ /** Filter text for agent names, or null when this provider should not claim the @ query. */
71
+ function agentSearchQuery(raw: string): string | null {
72
+ if (raw.startsWith("session:")) return null;
73
+ if (raw === "subagent") return "";
74
+ const prefixed = raw.match(/^subagent:\[?([^\]]*)$/);
75
+ if (prefixed) return prefixed[1] ?? "";
76
+ return raw;
77
+ }
78
+
79
+ export function extractSubagentReferenceNames(text: string): string[] {
80
+ const names: string[] = [];
81
+ const seen = new Set<string>();
82
+ for (const match of text.matchAll(SUBAGENT_REFERENCE_PATTERN)) {
83
+ const name = match[1]?.trim();
84
+ if (name && !seen.has(name)) {
85
+ seen.add(name);
86
+ names.push(name);
87
+ }
88
+ }
89
+ return names;
90
+ }
91
+
92
+ export function subagentReferenceValue(name: string): string {
93
+ return `${SUBAGENT_REFERENCE_PREFIX}[${name}]`;
94
+ }
95
+
63
96
  export function createAgentAutocompleteProvider(
64
97
  current: AutocompleteProvider,
65
98
  getAgents: () => AgentInfo[],
@@ -74,13 +107,16 @@ export function createAgentAutocompleteProvider(
74
107
  ): Promise<AutocompleteSuggestions | null> {
75
108
  const currentLine = lines[cursorLine] ?? "";
76
109
  const textBeforeCursor = currentLine.slice(0, cursorCol);
77
- const match = textBeforeCursor.match(AGENT_NAME_PATTERN);
110
+ const rawQuery = extractMentionQuery(textBeforeCursor);
111
+ if (rawQuery === undefined) {
112
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
113
+ }
78
114
 
79
- if (!match) {
115
+ const search = agentSearchQuery(rawQuery);
116
+ if (search === null) {
80
117
  return current.getSuggestions(lines, cursorLine, cursorCol, options);
81
118
  }
82
119
 
83
- const query = match[1]!;
84
120
  const agents = getAgents();
85
121
 
86
122
  if (options.signal.aborted || agents.length === 0) {
@@ -89,17 +125,20 @@ export function createAgentAutocompleteProvider(
89
125
 
90
126
  const [baseSuggestions, matches] = await Promise.all([
91
127
  current.getSuggestions(lines, cursorLine, cursorCol, options),
92
- Promise.resolve(query.trim() ? fuzzyFilter(agents, query, (a) => a.name) : agents),
128
+ Promise.resolve(search.trim() ? fuzzyFilter(agents, search, (a) => a.name) : agents),
93
129
  ]);
94
130
  if (options.signal.aborted) return null;
95
131
 
96
132
  const agentItems: AutocompleteItem[] = matches.slice(0, MAX_SUGGESTIONS).map((agent) => ({
97
- value: `@${agent.name}`,
133
+ value: subagentReferenceValue(agent.name),
98
134
  label: `[SubAgent] ${agent.displayName}`,
99
135
  description: `${agent.model ?? "?"} · ${agent.thinking ?? "?"}`,
100
136
  }));
101
- const hasCompatibleBaseSuggestions = baseSuggestions?.prefix === `@${query}`;
102
- const agentValues = new Set(agents.map((agent) => `@${agent.name}`));
137
+ const prefix = `@${rawQuery}`;
138
+ const hasCompatibleBaseSuggestions = baseSuggestions?.prefix === prefix;
139
+ const agentValues = new Set(
140
+ agents.flatMap((agent) => [`@${agent.name}`, subagentReferenceValue(agent.name)]),
141
+ );
103
142
  const baseItems = hasCompatibleBaseSuggestions
104
143
  ? baseSuggestions.items.filter((item) => !agentValues.has(item.value))
105
144
  : [];
@@ -112,7 +151,7 @@ export function createAgentAutocompleteProvider(
112
151
  });
113
152
 
114
153
  if (items.length === 0 && !hasCompatibleBaseSuggestions) return baseSuggestions;
115
- return { items, prefix: `@${query}` };
154
+ return { items, prefix };
116
155
  },
117
156
 
118
157
  applyCompletion(
@@ -166,16 +205,13 @@ export default function agentAutocompleteExtension(pi: ExtensionAPI): void {
166
205
  sessionGeneration++;
167
206
  });
168
207
 
169
- // Inject instruction when user types @agent-name (not @session:) in prompt.
170
- // Supports multiple different subagents in a single prompt.
171
- const AGENT_PROMPT_PATTERN = /(?:^|[\s])@([^\s:@][^\s:]*)/g;
208
+ // Inject as a request message, not systemPrompt, so the cached prefix stays stable.
172
209
  pi.on("before_agent_start", async (event, _ctx) => {
173
210
  const agents = getAgents();
174
211
  if (agents.length === 0) return;
175
212
 
176
213
  const mentions: string[] = [];
177
- for (const m of event.prompt.matchAll(AGENT_PROMPT_PATTERN)) {
178
- const name = m[1]!;
214
+ for (const name of extractSubagentReferenceNames(event.prompt)) {
179
215
  if (agents.some((a) => a.name === name) && !mentions.includes(name)) {
180
216
  mentions.push(name);
181
217
  }
@@ -186,12 +222,15 @@ export default function agentAutocompleteExtension(pi: ExtensionAPI): void {
186
222
  const agentList = mentions.map((n) => `"${n}" (${agentMap.get(n)!.displayName})`).join(", ");
187
223
 
188
224
  return {
189
- systemPrompt:
190
- event.systemPrompt +
191
- `\n\nThe user's prompt references these subagent types: ${agentList}. ` +
192
- `You MUST use the Agent tool for EACH mentioned subagent to delegate the relevant parts of the request. ` +
193
- `Handle different subagents separately — do NOT merge their tasks into a single Agent call. ` +
194
- `For example, if the user mentions @coder and @explore, make two separate Agent tool calls, one with subagent_type="coder" and another with subagent_type="explore".`,
225
+ message: {
226
+ customType: SUBAGENT_DELEGATION_CUSTOM_TYPE,
227
+ content:
228
+ `The user's prompt references these subagent types: ${agentList}. ` +
229
+ `You MUST use the Agent tool for EACH mentioned subagent to delegate the relevant parts of the request. ` +
230
+ `Handle different subagents separately — do NOT merge their tasks into a single Agent call. ` +
231
+ `For example, if the user mentions @subagent:[coder] and @subagent:[explore], make two separate Agent tool calls, one with subagent_type="coder" and another with subagent_type="explore".`,
232
+ display: false,
233
+ },
195
234
  };
196
235
  });
197
236
  }
@@ -36,7 +36,13 @@ import { showMoreHintText } from "./tool/show-more-hint.ts";
36
36
  import { countWriteDiffStats } from "./tool/diff/diff-renderer.ts";
37
37
  import { renderRichToolResult, type WriteExecutionMetadataStore } from "./tool/diff/index.ts";
38
38
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
39
- import { fitToolCallSummary, humanizeToolLabel, toolCallSummary } from "./tool/names.ts";
39
+ import { mcpToolTitle } from "./tool/mcp-title.ts";
40
+ import {
41
+ fitToolCallSummary,
42
+ humanizeToolLabel,
43
+ renderToolSummary,
44
+ toolCallSummary,
45
+ } from "./tool/names.ts";
40
46
 
41
47
  // 成功勾:亮绿 truecolor(与 message-display 一致)
42
48
  const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
@@ -102,25 +108,6 @@ export function shouldRenderRichDiff(
102
108
  return mode === "on" && !isError && (toolName === "edit" || toolName === "write");
103
109
  }
104
110
 
105
- export function isMcpToolDefinition(definition: any, toolName: string): boolean {
106
- const label = typeof definition?.label === "string" ? definition.label.trim() : "";
107
- if (/^MCP(?::|$)/i.test(label)) return true;
108
- if (toolName === "mcp" || /^mcp[_:-]|[_:-]mcp[_:-]/i.test(toolName)) return true;
109
- if (label) return false;
110
- const description = typeof definition?.description === "string" ? definition.description : "";
111
- return /\bModel Context Protocol\b/i.test(description);
112
- }
113
-
114
- export function humanizeMcpToolName(toolName: string): string {
115
- const words = toolName
116
- .replace(/^mcp(?:[_:-]+)+/i, "")
117
- .split(/[_:-]+/)
118
- .filter(Boolean);
119
- return words.length
120
- ? words.map((word) => word[0]!.toUpperCase() + word.slice(1)).join(" ")
121
- : "MCP";
122
- }
123
-
124
111
  /** 排除名单内且自带 renderer 的工具保留原渲染。 */
125
112
  export function preservesOriginalRenderer(
126
113
  extensionDefinition: any,
@@ -225,9 +212,10 @@ function createCcstyleTool(
225
212
  writeExecutionMetadata: WriteExecutionMetadataStore,
226
213
  ): any {
227
214
  const toolName = originalTool.name;
228
- const label = isMcpToolDefinition(originalTool, toolName)
229
- ? humanizeMcpToolName(toolName)
230
- : originalTool.label || toolName;
215
+ const label = originalTool.label || toolName;
216
+ const defaultTitle = label === toolName ? humanizeToolLabel(label) : label;
217
+ // MCP 工具直接用 adapter 暴露的真实工具名,不做人性化
218
+ const title = mcpToolTitle({ toolName, definition: originalTool }) ?? defaultTitle;
231
219
 
232
220
  return {
233
221
  ...originalTool,
@@ -252,7 +240,7 @@ function createCcstyleTool(
252
240
  // 不再每次 tick 走 updateDisplay 把整张卡重建一遍。
253
241
  const pendingIconStyled = () => theme.fg(toolIconColor(context), pendingIcon(toolName));
254
242
  const summary = toolCallSummary(toolName, args, {
255
- title: label === toolName ? humanizeToolLabel(label) : label,
243
+ title,
256
244
  variant: "default",
257
245
  cwd: context?.cwd,
258
246
  });
@@ -295,7 +283,7 @@ function createCcstyleTool(
295
283
  cachedWidth = width;
296
284
  cachedIcon = icon;
297
285
  // 路径按最终可用宽度中间截断,避免整行二次截断隐藏文件名。
298
- cachedLine = `${lead}${icon} ${theme.fg("toolTitle", fitToolCallSummary(summary, mainWidth))}${extraStyled}`;
286
+ cachedLine = `${lead}${icon} ${renderToolSummary(summary, mainWidth, theme.fg.bind(theme))}${extraStyled}`;
299
287
  return [truncateToWidth(cachedLine, viewportWidth, "")];
300
288
  },
301
289
  invalidate() {
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { ToolExecutionComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { CompactThinkingController } from "../feature/compact-thinking.ts";
3
3
  import { installToolGrouping, type ToolGroupingHooks } from "./tool/grouping.ts";
4
4
  import {
@@ -266,11 +266,13 @@ export default function (
266
266
  // ---- 对外导出:入口/测试实际消费的符号 ----
267
267
  export { getCompactThinkingConfig } from "../config/config.ts";
268
268
  export {
269
- humanizeMcpToolName,
270
- isMcpToolDefinition,
271
269
  preservesOriginalRenderer,
272
270
  shouldRenderRichDiff,
273
271
  } from "./default-mode.ts";
272
+ export {
273
+ isMcpToolDefinition,
274
+ mcpToolTitle,
275
+ } from "./tool/mcp-title.ts";
274
276
  export {
275
277
  ExpandedToolIoView,
276
278
  ExpandedToolResultText,
@@ -1,3 +1,4 @@
1
+ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
1
2
  import { hasActiveTextPreview, showTextPreview } from "../../feature/context.ts";
2
3
  import { ThinkingPreviewBlock } from "../../feature/compact-thinking.ts";
3
4
  import { patchRegistry, TOOL_MOUSE_OWNER_KEY } from "../../utils/patch-keys.ts";
@@ -311,6 +312,31 @@ function releaseFullscreenToolMouseMotion(tui?: any): void {
311
312
  const FULLSCREEN_VIEWPORT_PATCH = Symbol("ccstyle.fullscreen-viewport-patch");
312
313
  const FULLSCREEN_WHEEL_SCROLL_ORIGINAL = Symbol("ccstyle.fullscreen-wheel-scroll-original");
313
314
 
315
+ /**
316
+ * diff 结果组件自带 remainder 行:声明存在时只有那一行是展开入口(正文里的同名字样不算);
317
+ * 未声明的组件继续用文本规则。
318
+ */
319
+ function isCollapsedHintRow(component: any, finalLine: string): boolean {
320
+ const result = component?.resultRendererComponent;
321
+ return typeof result?.isCollapsedHintLine === "function"
322
+ ? result.isCollapsedHintLine(finalLine)
323
+ : true;
324
+ }
325
+
326
+ /**
327
+ * pi 0.87 给工具结果区套了 MouseRegion:整卡左键 click 都会 setExpanded。
328
+ * 命中该区域时官方会跳过文本选区,所以可以安全吞掉;单行摘要卡没有声明
329
+ * remainder 行,继续走官方整行行为。
330
+ */
331
+ function blocksOfficialCardToggle(component: any, event: any): boolean {
332
+ if (event?.type !== "click" || event.button !== "left" || component?.expanded) return false;
333
+ const result = component?.resultRendererComponent;
334
+ if (typeof result?.isCollapsedHintLine !== "function") return false;
335
+ const lines = component.render?.(Math.max(1, Math.floor(Number(event.width) || 0)));
336
+ const line = Array.isArray(lines) ? lines[event.y] : undefined;
337
+ return typeof line === "string" && !result.isCollapsedHintLine(line);
338
+ }
339
+
314
340
  /**
315
341
  * 官方 fullscreen 工具卡点击:collapsed hint 单击展开
316
342
  * (有且仅保持一个展开:展开前收起其他工具卡),expanded 整卡双击收起,
@@ -352,6 +378,7 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
352
378
  // collapsed 仅按钮文本可展开,不能把同一行正文/留白变成点击区。
353
379
  const hint = collapsedHintHitbox(line);
354
380
  if (!hint || packet.col < hint.startCol || packet.col > hint.endCol) return false;
381
+ if (!isCollapsedHintRow(component, line)) return false;
355
382
  // single-expand:展开前收起其他已展开工具卡/group。
356
383
  const others: any[] = [];
357
384
  collectFullscreenToolCards(hit.box.component, others);
@@ -574,7 +601,11 @@ function buildInteractionFrame(
574
601
  const line = placement.finalLine;
575
602
  if (!component.expanded) {
576
603
  const box = collapsedHintHitbox(line);
577
- if (box && COLLAPSED_TOOL_SUMMARY.test(stripTerminalSequences(line))) {
604
+ if (
605
+ box &&
606
+ COLLAPSED_TOOL_SUMMARY.test(stripTerminalSequences(line)) &&
607
+ isCollapsedHintRow(component, line)
608
+ ) {
578
609
  regions.push({ kind: "collapsed-hint", row: finalRow, ...box, component });
579
610
  }
580
611
  continue;
@@ -867,6 +898,7 @@ export function teardownToolMouseInteraction(
867
898
  setHoveredMessageDisplay(null);
868
899
  setHoveredToolIo(null, null);
869
900
  setHoveredCompactAssistant(null);
901
+ releaseToolCardMouseGuard();
870
902
  clearExpandPanelDoubleClick();
871
903
  try {
872
904
  if (isLazyProxyTui(getToolMouseTui())) releaseFullscreenToolMouseMotion(getToolMouseTui());
@@ -901,6 +933,43 @@ export function resetToolHoverState(): void {
901
933
  releaseFullscreenToolMouseMotion(getToolMouseTui());
902
934
  }
903
935
 
936
+ /**
937
+ * pi 0.87 起工具卡结果区自带 MouseRegion,整卡左键 click 都会 setExpanded。
938
+ * ccstyle 的多行折叠卡(rich diff)只允许声明的 remainder 行展开,
939
+ * 其余位置直接吞掉 click,避免官方把 diff 正文当展开入口。
940
+ * 0.84 及更早没有 handleMouse,安装时跳过。
941
+ */
942
+ const TOOL_CARD_MOUSE_GUARD_KEY = Symbol.for("pi.ccstyle.tool-card-mouse-guard");
943
+
944
+ type ToolCardMouseGuard = {
945
+ prototype: any;
946
+ original: (event: any) => any;
947
+ wrapper: (event: any) => any;
948
+ };
949
+
950
+ function installToolCardMouseGuard(): void {
951
+ const prototype = (ToolExecutionComponent as any)?.prototype;
952
+ if (typeof prototype?.handleMouse !== "function") return;
953
+ const previous = (globalThis as any)[TOOL_CARD_MOUSE_GUARD_KEY] as ToolCardMouseGuard | undefined;
954
+ if (previous && previous.prototype === prototype && prototype.handleMouse === previous.wrapper)
955
+ return;
956
+ // 每次取当前值作为下游,可能是别的扩展的包装。
957
+ const original = prototype.handleMouse;
958
+ const wrapper = function (this: any, event: any) {
959
+ if (blocksOfficialCardToggle(this, event)) return { handled: true };
960
+ return original.call(this, event);
961
+ };
962
+ prototype.handleMouse = wrapper;
963
+ (globalThis as any)[TOOL_CARD_MOUSE_GUARD_KEY] = { prototype, original, wrapper };
964
+ }
965
+
966
+ function releaseToolCardMouseGuard(): void {
967
+ const guard = (globalThis as any)[TOOL_CARD_MOUSE_GUARD_KEY] as ToolCardMouseGuard | undefined;
968
+ if (!guard) return;
969
+ if (guard.prototype?.handleMouse === guard.wrapper) guard.prototype.handleMouse = guard.original;
970
+ (globalThis as any)[TOOL_CARD_MOUSE_GUARD_KEY] = undefined;
971
+ }
972
+
904
973
  export function installToolMouseInteraction(
905
974
  ctx: any,
906
975
  owner: object = DEFAULT_TOOL_MOUSE_OWNER,
@@ -912,6 +981,7 @@ export function installToolMouseInteraction(
912
981
 
913
982
  toolMouseInstallationOwner = owner;
914
983
  patchRegistry.install(TOOL_MOUSE_OWNER_KEY, owner);
984
+ installToolCardMouseGuard();
915
985
  setHoveredToolCallId(null);
916
986
  toolMouseUi = ctx.ui;
917
987
  // 0.84+ 的 tui 是惰性 Proxy:regular 保留原生 scrollback;fullscreen
@@ -3,8 +3,9 @@ import {
3
3
  type DiffIndicatorMode,
4
4
  type ToolDisplayConfig,
5
5
  } from "../../../config/config.ts";
6
- import type { DiffPresentationMode } from "./diff-presentation.ts";
6
+ import { stripTerminalSequencesPreservingLayout } from "../../../utils/ansi-text.ts";
7
7
  import { RICH_DIFF_COMPONENT } from "../../../utils/patch-keys.ts";
8
+ import type { DiffPresentationMode } from "./diff-presentation.ts";
8
9
  export { RICH_DIFF_COMPONENT };
9
10
 
10
11
  /** Snapshot or live getter — panel changes must apply on the next paint. */
@@ -33,6 +34,15 @@ export function resolveLiveDisplayConfig(input: DisplayConfigInput): ToolDisplay
33
34
  return typeof input === "function" ? input() : input;
34
35
  }
35
36
 
37
+ /**
38
+ * 比对用归一化:ANSI、缩进前缀和右侧填充不影响判断。
39
+ * 折叠态 remainder 行里有任意正文,入口必须先由组件声明再比对文本。
40
+ */
41
+ export function normalizeCollapsedHintLine(line: string | undefined): string | undefined {
42
+ if (line === undefined) return undefined;
43
+ return stripTerminalSequencesPreservingLayout(line).replace(/\s+/g, " ").trim();
44
+ }
45
+
36
46
  /** Cache key fragment so indicator/wrap/limits invalidate without host recreate. */
37
47
  export function displayConfigCacheKey(config: ToolDisplayConfig): string {
38
48
  return [
@@ -42,7 +52,6 @@ export function displayConfigCacheKey(config: ToolDisplayConfig): string {
42
52
  String(config.editDiffCollapsedLines),
43
53
  String(config.writeDiffCollapsedLines),
44
54
  config.diffWordWrap ? "1" : "0",
45
- String(config.expandedPreviewMaxLines),
46
55
  ].join(":");
47
56
  }
48
57
 
@@ -59,6 +68,8 @@ export function createDiffRenderCache() {
59
68
  let cachedConfigKey: string | undefined;
60
69
  let cachedHovered: boolean | undefined;
61
70
  let cachedLines: string[] | undefined;
71
+ /** 与 cachedLines 同一次渲染的折叠态 remainder 行文本。 */
72
+ let cachedHintLine: string | undefined;
62
73
 
63
74
  return {
64
75
  get(
@@ -80,6 +91,9 @@ export function createDiffRenderCache() {
80
91
  }
81
92
  return undefined;
82
93
  },
94
+ getHintLine(): string | undefined {
95
+ return cachedHintLine;
96
+ },
83
97
  set(
84
98
  width: number,
85
99
  expanded: boolean,
@@ -87,6 +101,7 @@ export function createDiffRenderCache() {
87
101
  configKey: string,
88
102
  hovered: boolean,
89
103
  lines: string[],
104
+ hintLine?: string,
90
105
  ): string[] {
91
106
  cachedWidth = width;
92
107
  cachedExpanded = expanded;
@@ -94,6 +109,7 @@ export function createDiffRenderCache() {
94
109
  cachedConfigKey = configKey;
95
110
  cachedHovered = hovered;
96
111
  cachedLines = lines;
112
+ cachedHintLine = hintLine;
97
113
  return lines;
98
114
  },
99
115
  invalidate(): void {
@@ -103,6 +119,7 @@ export function createDiffRenderCache() {
103
119
  cachedConfigKey = undefined;
104
120
  cachedHovered = undefined;
105
121
  cachedLines = undefined;
122
+ cachedHintLine = undefined;
106
123
  },
107
124
  };
108
125
  }
@@ -32,6 +32,7 @@ import {
32
32
  displayConfigCacheKey,
33
33
  resolveDiffIndicatorMode,
34
34
  resolveLiveDisplayConfig,
35
+ normalizeCollapsedHintLine,
35
36
  type DiffRenderOptions,
36
37
  type DisplayConfigInput,
37
38
  } from "./diff-component.ts";
@@ -127,6 +128,11 @@ export function renderEditDiffResult(
127
128
 
128
129
  return {
129
130
  [RICH_DIFF_COMPONENT]: true,
131
+ /** 鼠标层只在真正的 remainder 行上提供展开入口。 */
132
+ isCollapsedHintLine(plainLine: string): boolean {
133
+ const hint = cache.getHintLine();
134
+ return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
135
+ },
130
136
  render(width: number): string[] {
131
137
  // Live config: panel can change indicator/wrap/limits after this component is created.
132
138
  const live = resolveLiveDisplayConfig(config);
@@ -161,11 +167,7 @@ export function renderEditDiffResult(
161
167
  }
162
168
 
163
169
  const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
164
- const displayLimit = resolveDiffDisplayLimit(
165
- options.expanded,
166
- live.editDiffCollapsedLines,
167
- live.expandedPreviewMaxLines,
168
- );
170
+ const displayLimit = resolveDiffDisplayLimit(options.expanded, live.editDiffCollapsedLines);
169
171
  const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
170
172
  // Only highlight/render a prefix that can fill the display limit; full-diff
171
173
  // LCS + syntax highlight on thousands of hidden lines is pure waste when collapsed.
@@ -198,7 +200,6 @@ export function renderEditDiffResult(
198
200
  safeWidth,
199
201
  options.expanded,
200
202
  live.editDiffCollapsedLines,
201
- live.expandedPreviewMaxLines,
202
203
  parsed.stats.hunks,
203
204
  theme,
204
205
  unprocessedLogicalRows,
@@ -207,11 +208,19 @@ export function renderEditDiffResult(
207
208
  const frame = renderDiffFrameLine(safeWidth, theme);
208
209
  const renderedLines =
209
210
  mode === "unified"
210
- ? [...headerRows.map((row) => row.text), frame, ...bodyWithLimit, frame]
211
- : [...headerRows.map((row) => row.text), ...bodyWithLimit];
211
+ ? [...headerRows.map((row) => row.text), frame, ...bodyWithLimit.lines, frame]
212
+ : [...headerRows.map((row) => row.text), ...bodyWithLimit.lines];
212
213
 
213
214
  const clampedLines = clampDiffLinesToWidth(renderedLines, safeWidth);
214
- return cache.set(safeWidth, options.expanded, mode, configKey, hovered, clampedLines);
215
+ return cache.set(
216
+ safeWidth,
217
+ options.expanded,
218
+ mode,
219
+ configKey,
220
+ hovered,
221
+ clampedLines,
222
+ normalizeCollapsedHintLine(bodyWithLimit.hintLine),
223
+ );
215
224
  },
216
225
  invalidate: cache.invalidate,
217
226
  } as Component;
@@ -31,18 +31,16 @@ function renderDiffSpacerLine(width: number): string {
31
31
  return safeWidth > 0 ? " ".repeat(safeWidth) : "";
32
32
  }
33
33
 
34
- export function resolveDiffDisplayLimit(
35
- expanded: boolean,
36
- maxCollapsedLines: number,
37
- maxExpandedLines: number,
38
- ): number {
39
- const expandedLimit = Number.isFinite(maxExpandedLines)
40
- ? maxExpandedLines
41
- : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
34
+ /**
35
+ * Body lines to render before the remainder hint. Expanded diffs are never capped:
36
+ * clicking `click to show more` expands the card to the whole diff.
37
+ */
38
+ export function resolveDiffDisplayLimit(expanded: boolean, maxCollapsedLines: number): number {
39
+ if (expanded) return Number.POSITIVE_INFINITY;
42
40
  const collapsedLimit = Number.isFinite(maxCollapsedLines)
43
41
  ? maxCollapsedLines
44
42
  : DEFAULT_TOOL_DISPLAY_CONFIG.editDiffCollapsedLines;
45
- return expanded ? Math.max(0, expandedLimit) : Math.max(1, collapsedLimit);
43
+ return Math.max(1, collapsedLimit);
46
44
  }
47
45
 
48
46
  export function resolveWriteCollapsedLimit(
@@ -99,7 +97,6 @@ export function applyLineLimit(
99
97
  width: number,
100
98
  expanded: boolean,
101
99
  maxCollapsedLines: number,
102
- maxExpandedLines: number,
103
100
  totalHunks: number,
104
101
  theme: DiffTheme,
105
102
  /**
@@ -108,17 +105,17 @@ export function applyLineLimit(
108
105
  */
109
106
  unprocessedLogicalRows = 0,
110
107
  hovered = false,
111
- ): string[] {
112
- const limit = resolveDiffDisplayLimit(expanded, maxCollapsedLines, maxExpandedLines);
108
+ ): { lines: string[]; hintLine?: string } {
109
+ const limit = resolveDiffDisplayLimit(expanded, maxCollapsedLines);
113
110
  const safeUnprocessed = Math.max(0, unprocessedLogicalRows);
114
111
  if (limit === 0 || (rows.length <= limit && safeUnprocessed === 0)) {
115
- return rows.map((row) => clampDiffLineToWidth(row.text, width));
112
+ return { lines: rows.map((row) => clampDiffLineToWidth(row.text, width)) };
116
113
  }
117
114
 
118
115
  const shown = rows.length <= limit ? rows : rows.slice(0, limit);
119
116
  const remaining = Math.max(0, rows.length - shown.length) + safeUnprocessed;
120
117
  if (remaining === 0) {
121
- return shown.map((row) => clampDiffLineToWidth(row.text, width));
118
+ return { lines: shown.map((row) => clampDiffLineToWidth(row.text, width)) };
122
119
  }
123
120
  const visibleHunks = new Set(
124
121
  shown
@@ -137,15 +134,19 @@ export function applyLineLimit(
137
134
 
138
135
  const clickLabel = showMoreHintText();
139
136
  const clickIndex = hintText.lastIndexOf(clickLabel);
137
+ // 展开态不会再截断,剩下的分支必是折叠态提示。
140
138
  const styledHint =
141
- hovered && !expanded && clickIndex >= 0
139
+ hovered && clickIndex >= 0
142
140
  ? theme.fg("muted", hintText.slice(0, clickIndex)) +
143
141
  theme.fg("text", clickLabel) +
144
142
  theme.fg("muted", hintText.slice(clickIndex + clickLabel.length))
145
- : theme.fg(expanded ? "warning" : "muted", hintText);
146
- return [
147
- ...shown.map((row) => clampDiffLineToWidth(row.text, width)),
148
- renderDiffSpacerLine(width),
149
- clampDiffLineToWidth(styledHint, width),
150
- ];
143
+ : theme.fg("muted", hintText);
144
+ return {
145
+ lines: [
146
+ ...shown.map((row) => clampDiffLineToWidth(row.text, width)),
147
+ renderDiffSpacerLine(width),
148
+ clampDiffLineToWidth(styledHint, width),
149
+ ],
150
+ hintLine: hintText,
151
+ };
151
152
  }
@@ -46,6 +46,7 @@ import {
46
46
  displayConfigCacheKey,
47
47
  resolveDiffIndicatorMode,
48
48
  resolveLiveDisplayConfig,
49
+ normalizeCollapsedHintLine,
49
50
  type DiffRenderOptions,
50
51
  type DisplayConfigInput,
51
52
  } from "./diff-component.ts";
@@ -407,6 +408,11 @@ export function renderWriteDiffResult(
407
408
 
408
409
  return {
409
410
  [RICH_DIFF_COMPONENT]: true,
411
+ /** 鼠标层只在真正的 remainder 行上提供展开入口。 */
412
+ isCollapsedHintLine(plainLine: string): boolean {
413
+ const hint = cache.getHintLine();
414
+ return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
415
+ },
410
416
  render(width: number): string[] {
411
417
  // Live config: panel can change indicator/wrap/limits after this component is created.
412
418
  const live = resolveLiveDisplayConfig(config);
@@ -433,22 +439,21 @@ export function renderWriteDiffResult(
433
439
  const writeCollapsedLimit = resolveWriteCollapsedLimit(live);
434
440
  const statsOnlyCollapsed = !options.expanded && writeCollapsedLimit === 0;
435
441
  if (statsOnlyCollapsed) {
442
+ const hintLines = renderWriteCollapsedHintLine(
443
+ options.fileExistedBeforeWrite === true,
444
+ safeWidth,
445
+ theme,
446
+ hovered,
447
+ options.headerLabel,
448
+ );
436
449
  return cache.set(
437
450
  safeWidth,
438
451
  options.expanded,
439
452
  mode,
440
453
  configKey,
441
454
  hovered,
442
- clampDiffLinesToWidth(
443
- renderWriteCollapsedHintLine(
444
- options.fileExistedBeforeWrite === true,
445
- safeWidth,
446
- theme,
447
- hovered,
448
- options.headerLabel,
449
- ),
450
- safeWidth,
451
- ),
455
+ clampDiffLinesToWidth(hintLines, safeWidth),
456
+ normalizeCollapsedHintLine(hintLines[0]),
452
457
  );
453
458
  }
454
459
 
@@ -500,11 +505,7 @@ export function renderWriteDiffResult(
500
505
  }
501
506
 
502
507
  const data = getDetailedData();
503
- const displayLimit = resolveDiffDisplayLimit(
504
- options.expanded,
505
- writeCollapsedLimit,
506
- live.expandedPreviewMaxLines,
507
- );
508
+ const displayLimit = resolveDiffDisplayLimit(options.expanded, writeCollapsedLimit);
508
509
  const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
509
510
  const entryBudget = takeEntriesForLineBudget(data.entries, processBudget);
510
511
  const splitBudget = takeSplitRowsForBudget(data.splitRows, processBudget);
@@ -541,7 +542,6 @@ export function renderWriteDiffResult(
541
542
  safeWidth,
542
543
  options.expanded,
543
544
  writeCollapsedLimit,
544
- live.expandedPreviewMaxLines,
545
545
  data.hunkCount,
546
546
  theme,
547
547
  unprocessedLogicalRows,
@@ -549,9 +549,19 @@ export function renderWriteDiffResult(
549
549
  );
550
550
  const frame = renderDiffFrameLine(safeWidth, theme);
551
551
  const renderedLines =
552
- mode === "unified" ? [header, frame, ...bodyWithLimit, frame] : [header, ...bodyWithLimit];
552
+ mode === "unified"
553
+ ? [header, frame, ...bodyWithLimit.lines, frame]
554
+ : [header, ...bodyWithLimit.lines];
553
555
  const finalLines = clampDiffLinesToWidth(renderedLines, safeWidth);
554
- return cache.set(safeWidth, options.expanded, mode, configKey, hovered, finalLines);
556
+ return cache.set(
557
+ safeWidth,
558
+ options.expanded,
559
+ mode,
560
+ configKey,
561
+ hovered,
562
+ finalLines,
563
+ normalizeCollapsedHintLine(bodyWithLimit.hintLine),
564
+ );
555
565
  },
556
566
  invalidate: cache.invalidate,
557
567
  } as Component;
@@ -8,12 +8,14 @@ import {
8
8
  } from "@earendil-works/pi-tui";
9
9
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
10
10
  import { getToolMouseTui } from "../mouse/scroll.ts";
11
+ import { mcpToolTitle } from "./mcp-title.ts";
11
12
  import { isToolTuiFullscreen, showMoreHintText } from "./show-more-hint.ts";
12
13
  import { stripAnsi, stripBackgroundAnsi, stripLeadingStatusIcon } from "../../utils/ansi-text.ts";
13
14
  import { walkComponentTree } from "../../utils/component-tree.ts";
14
15
  import {
15
16
  fitToolCallSummary,
16
17
  humanizeToolLabel,
18
+ renderToolSummary,
17
19
  toolCallSummary,
18
20
  type ToolCallSummary,
19
21
  } from "./names.ts";
@@ -44,6 +46,19 @@ function toolName(tool: any): string {
44
46
  return String(tool?.toolName ?? tool?.toolDefinition?.name ?? "tool");
45
47
  }
46
48
 
49
+ /**
50
+ * 标题:MCP 工具用 adapter 暴露的真实工具名,其余回退工具名人性化。
51
+ */
52
+ function toolTitle(tool: any): string {
53
+ const name = toolName(tool);
54
+ return (
55
+ mcpToolTitle({
56
+ toolName: name,
57
+ definition: tool?.toolDefinition ?? tool?.builtInToolDefinition,
58
+ }) ?? humanizeToolLabel(name)
59
+ );
60
+ }
61
+
47
62
  function isGroupable(value: unknown): boolean {
48
63
  return value instanceof ToolExecutionComponent && !NON_GROUPABLE.has(toolName(value));
49
64
  }
@@ -170,6 +185,7 @@ export function paddedBackgroundRow(
170
185
 
171
186
  function toolSummary(tool: any): ToolCallSummary {
172
187
  return toolCallSummary(toolName(tool), tool?.args ?? {}, {
188
+ title: toolTitle(tool),
173
189
  variant: "grouping",
174
190
  cwd: tool?.cwd,
175
191
  });
@@ -347,8 +363,7 @@ export class ToolGroupComponent extends Container {
347
363
  })
348
364
  .join(` ${fg("dim", "•")} `);
349
365
  const names = new Set(this.children.map(toolName));
350
- const label =
351
- names.size === 1 ? humanizeToolLabel(toolName(this.children[0])) : "Multiple Tools";
366
+ const label = names.size === 1 ? toolTitle(this.children[0]) : "Multiple Tools";
352
367
  const overall: ToolStatus = counts.error ? "error" : counts.pending ? "pending" : "success";
353
368
  if (
354
369
  (this.children as any[]).some((tool) => tool?.executionStarted && status(tool) === "pending")
@@ -398,7 +413,7 @@ export class ToolGroupComponent extends Container {
398
413
  const mainWidth = Math.max(0, width - visibleWidth(prefix) - visibleWidth(detail));
399
414
  lines.push(
400
415
  truncateToWidth(
401
- `${prefix}${fg("toolTitle", fitToolCallSummary(summary, mainWidth))}${detail}`,
416
+ `${prefix}${renderToolSummary(summary, mainWidth, fg)}${detail}`,
402
417
  width,
403
418
  "",
404
419
  ),
@@ -0,0 +1,39 @@
1
+ import { humanizeToolLabel } from "./names.ts";
2
+
3
+ /**
4
+ * MCP 工具的识别与标题,单卡(default-mode)与分组行/分组头(grouping)共用。
5
+ *
6
+ * - 判定:工具名带 mcp 片段(`mcp` 网关、`mcpScript`、`mcp__<server>`),或 adapter 注册时
7
+ * 给的 label 以 `MCP:` 开头。默认/none/short 前缀模式下直连工具的名字里没有 mcp 片段
8
+ * (`github_search_code`、`search_code`),只能靠 label 判定。
9
+ * - 标题:具体工具用 adapter 暴露的真实工具名,不做二次格式化;只有 `mcp` / `mcpScript`
10
+ * 这两个入口没有具体工具名,跟普通工具一样人性化(MCP / MCP Script)。
11
+ * - 参数摘要与其它工具共用 toolCallSummary 的字段链,网关额外把 `args.tool` 提到标题后。
12
+ */
13
+
14
+ /** 单次 MCP 调用的识别信息。 */
15
+ export type McpToolCall = {
16
+ toolName: string;
17
+ /** 工具定义;默认/none/short 前缀模式要靠 label 判定,会话重放时可能缺失。 */
18
+ definition?: any;
19
+ };
20
+
21
+ const GATEWAY_TOOL_NAME = "mcp";
22
+ const SCRIPT_TOOL_NAME = "mcpScript";
23
+
24
+ export function isMcpToolDefinition(definition: any, toolName: string): boolean {
25
+ const label = typeof definition?.label === "string" ? definition.label.trim() : "";
26
+ if (/^MCP(?::|$)/i.test(label)) return true;
27
+ if (toolName === GATEWAY_TOOL_NAME || toolName === SCRIPT_TOOL_NAME) return true;
28
+ return /^mcp[_:-]|[_:-]mcp[_:-]/i.test(toolName);
29
+ }
30
+
31
+ /** 标题:MCP 工具返回标题(入口走人性化,其余用真实工具名);非 MCP 返回 undefined。 */
32
+ export function mcpToolTitle(call: McpToolCall): string | undefined {
33
+ const { toolName } = call;
34
+ if (!isMcpToolDefinition(call.definition, toolName)) return undefined;
35
+ if (toolName === GATEWAY_TOOL_NAME || toolName === SCRIPT_TOOL_NAME) {
36
+ return humanizeToolLabel(toolName);
37
+ }
38
+ return toolName;
39
+ }
@@ -1,15 +1,23 @@
1
1
  import { posix, win32 } from "node:path";
2
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
3
  import { visibleWidth } from "@earendil-works/pi-tui";
3
4
  import { config } from "../../config/config.ts";
4
5
  import { oneLine } from "../../utils/format.ts";
6
+ import { headTruncateToWidth } from "./result.ts";
5
7
 
6
8
  function clip(value: unknown): string {
7
9
  return oneLine(value, config.inputClip);
8
10
  }
9
11
 
12
+ /** 载荷(入参 JSON / 脚本代码)至少留出这么多宽度才显示,否则整段省略(避免只剩一个孤零零的 " {…")。 */
13
+ const MIN_PAYLOAD_WIDTH = 10;
14
+
10
15
  /** 品牌大小写固定写法;humanize 推导不出来的工具名写在这里。 */
11
16
  const TOOL_LABEL_OVERRIDES: Readonly<Record<string, string>> = {
12
17
  powershell: "PowerShell",
18
+ // MCP 入口两个没有具体工具名,跟普通工具一样人性化;缩写固定大写
19
+ mcp: "MCP",
20
+ mcpscript: "MCP Script",
13
21
  };
14
22
 
15
23
  /**
@@ -49,6 +57,8 @@ export type ToolCallSummary = {
49
57
  detail: string;
50
58
  /** 路径摘要保留结构,供最终渲染按实际宽度优先保留文件名。 */
51
59
  path?: { prefix: string; value: string };
60
+ /** 标题之外的原始载荷(完整入参 JSON、脚本代码…),已压成单行,渲染时用 dim 接在标题后。 */
61
+ payload?: string;
52
62
  };
53
63
 
54
64
  function pathApi(value: string) {
@@ -229,6 +239,18 @@ export function toolCallSummary(
229
239
  const variant = opts.variant ?? "default";
230
240
  if (!args || typeof args !== "object") return { main: title, detail: "" };
231
241
  const name = toolName.toLowerCase();
242
+
243
+ // MCP 入口沿用 mcp-adapter 自己的标题风格(adapter 的 formatMcpProxyToolCallLines):
244
+ // `MCP <动作> <目标>` / `MCP Script <代码>`,比把整包参数摊成 JSON 好读
245
+ if (name === "mcp") {
246
+ const gateway = mcpGatewaySummary(title, args);
247
+ if (gateway) return gateway;
248
+ }
249
+ if (name === "mcpscript") {
250
+ const code = typeof args.code === "string" && args.code ? clip(args.code) : "";
251
+ return code ? { main: title, detail: "", payload: code } : { main: title, detail: "" };
252
+ }
253
+
232
254
  const value = (fallback: string, ...keys: string[]) => {
233
255
  const found = keys.map((key) => args[key]).find((item) => typeof item === "string" && item);
234
256
  return `${title} ${clip(found || fallback)}`;
@@ -315,5 +337,85 @@ export function toolCallSummary(
315
337
  if (typeof preferredPath === "string" && preferredPath) {
316
338
  return pathSummary(title, preferredPath, opts.cwd);
317
339
  }
318
- return { main: title, detail: "" };
340
+
341
+ // 字段链认不出的参数(命名空间代理的 tool+args、第三方工具的自定义键…)展开完整入参 JSON,
342
+ // 而不是只剩一个标题;网关与脚本的专属形状在上面处理
343
+ const payload = jsonArgs(args);
344
+ return payload ? { main: title, detail: "", payload } : { main: title, detail: "" };
345
+ }
346
+
347
+ /**
348
+ * MCP 网关注解 `mcp <动作> <目标>`:动作与目标照搬 mcp-adapter 的 formatMcpProxyToolCallLines,
349
+ * 人眼读起来比参数 JSON 快;`instructions` 是 adapter 自己漏掉的一档(它那里会落到 mcp status)。
350
+ * 认不出的入参返回 undefined,交给通用 JSON 回退,不猜成 status。
351
+ */
352
+ function mcpGatewaySummary(title: string, args: any): ToolCallSummary | undefined {
353
+ const text = (value: unknown) => (typeof value === "string" && value ? value : "");
354
+ const tool = text(args.tool);
355
+ const connect = text(args.connect);
356
+ const describe = text(args.describe);
357
+ const instructions = text(args.instructions);
358
+ const search = text(args.search);
359
+ const server = text(args.server);
360
+ const action = text(args.action);
361
+ const scoped = (target: string) => (server ? `${target} @ ${server}` : target);
362
+
363
+ if (action === "ui-messages") return { main: `${title} ${action}`, detail: "" };
364
+ if (tool) {
365
+ const main = `${title} call ${scoped(tool)}`;
366
+ const payload = innerArgsPayload(args.args);
367
+ return payload ? { main, detail: "", payload } : { main, detail: "" };
368
+ }
369
+ if (connect) return { main: `${title} connect ${connect}`, detail: "" };
370
+ if (describe) return { main: `${title} describe ${scoped(describe)}`, detail: "" };
371
+ if (instructions) return { main: `${title} instructions ${instructions}`, detail: "" };
372
+ if (search) {
373
+ const flags = [
374
+ args.regex === true ? "regex" : "",
375
+ args.includeSchemas === false ? "schemas hidden" : "",
376
+ ].filter(Boolean);
377
+ return {
378
+ main: `${title} search ${scoped(search)}`,
379
+ detail: flags.length ? ` (${flags.join(", ")})` : "",
380
+ };
381
+ }
382
+ if (server) return { main: `${title} list ${server}`, detail: "" };
383
+ if (action) return { main: `${title} ${action}`, detail: "" };
384
+ if (Object.keys(args).length === 0) return { main: `${title} status`, detail: "" };
385
+ return undefined;
386
+ }
387
+
388
+ /** 网关 call 的内层工具入参:对象转单行 JSON;网关允许传 JSON 字符串,就原样取用不再转义。 */
389
+ function innerArgsPayload(value: unknown): string {
390
+ if (typeof value === "string") return value ? clip(value) : "";
391
+ if (!value || typeof value !== "object") return "";
392
+ return jsonArgs(value);
393
+ }
394
+
395
+ /** 入参序列化成单行 JSON;空对象或不可序列化时返回 ""。 */
396
+ function jsonArgs(args: any): string {
397
+ try {
398
+ const json = JSON.stringify(args);
399
+ return json && json !== "{}" ? clip(json) : "";
400
+ } catch {
401
+ return "";
402
+ }
403
+ }
404
+
405
+ /**
406
+ * 标题行内容:标题用调用方的 toolTitle 色,原始载荷(入参 JSON / 脚本代码)单独用 dim。
407
+ * 标题优先占宽(它是身份),剩下的宽度给载荷;载荷超宽时尾部省略,连
408
+ * MIN_PAYLOAD_WIDTH 都放不下时整段省略。
409
+ */
410
+ export function renderToolSummary(
411
+ summary: ToolCallSummary,
412
+ width: number,
413
+ fg: (color: ThemeColor, text: string) => string,
414
+ ): string {
415
+ const title = fitToolCallSummary(summary, width);
416
+ if (!summary.payload) return fg("toolTitle", title);
417
+ const room = width - visibleWidth(title);
418
+ return room >= MIN_PAYLOAD_WIDTH
419
+ ? `${fg("toolTitle", title)}${fg("dim", headTruncateToWidth(` ${summary.payload}`, room))}`
420
+ : fg("toolTitle", title);
319
421
  }
@@ -14,7 +14,7 @@ export function toolViewportWidth(width: number): number {
14
14
 
15
15
  /** 与默认工具结果相同的一级缩进包装。子组件只扣始终加上的 1 列;↳ 行多出的 2 列由 truncateToWidth 吃掉。 */
16
16
  export function insetComponent(component: any): any {
17
- return {
17
+ const wrapped: Record<string, unknown> = {
18
18
  render: (width: number) =>
19
19
  component.render(Math.max(1, width - 1)).map((line: string) => {
20
20
  const nestedMarker = line.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*)↳/, "$1 ↳");
@@ -22,6 +22,11 @@ export function insetComponent(component: any): any {
22
22
  }),
23
23
  invalidate: () => component.invalidate?.(),
24
24
  };
25
+ // 内层 diff 声明的 remainder 行:鼠标层只能看到这个包装组件。
26
+ if (typeof component.isCollapsedHintLine === "function") {
27
+ wrapped.isCollapsedHintLine = (line: string) => component.isCollapsedHintLine(line);
28
+ }
29
+ return wrapped;
25
30
  }
26
31
 
27
32
  function rawTextFromResult(result: any): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",