pi-cc-extensions 0.8.26 → 0.8.28

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.
@@ -6,6 +6,12 @@ import {
6
6
  type CompactStyleMode,
7
7
  } from "./compact-style.ts";
8
8
  import { showTextPreview } from "./context.ts";
9
+ import {
10
+ installToolGrouping,
11
+ ToolGroupComponent,
12
+ type ToolGroupingHooks,
13
+ } from "./tool-grouping.ts";
14
+ import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "./tool-loading-icon.ts";
9
15
  import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
10
16
  import {
11
17
  DEFAULT_TOOL_DISPLAY_CONFIG,
@@ -27,6 +33,7 @@ import {
27
33
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
28
34
  import { homedir } from "node:os";
29
35
  import { join } from "node:path";
36
+ import { inspect } from "node:util";
30
37
 
31
38
  /**
32
39
  * Claude Code Style for pi.
@@ -238,13 +245,30 @@ function oneLine(value: unknown, max = 72): string {
238
245
  }
239
246
 
240
247
  function rawTextFromResult(result: any): string {
241
- const item = result?.content?.find?.((c: any) => c.type === "text");
242
- return item?.type === "text" ? String(item.text ?? "") : "";
248
+ return Array.isArray(result?.content)
249
+ ? result.content
250
+ .filter((item: any) => item?.type === "text")
251
+ .map((item: any) => String(item.text ?? ""))
252
+ .join("\n")
253
+ : "";
254
+ }
255
+
256
+ function detailsFromResult(result: any): string {
257
+ if (result?.details === undefined) return "";
258
+ const details =
259
+ typeof result.details === "string"
260
+ ? result.details
261
+ : inspect(result.details, { depth: 8, breakLength: 100, compact: false });
262
+ return sanitizeToolResultText(details, 16_384);
243
263
  }
244
264
 
245
- function textFromResult(result: any): string {
265
+ function textFromResult(result: any, expanded = false): string {
246
266
  // Compact previews only need short text; bound sanitize work.
247
- return sanitizeToolResultText(rawTextFromResult(result), 16_384);
267
+ const content = sanitizeToolResultText(rawTextFromResult(result), 16_384);
268
+ const details = detailsFromResult(result);
269
+ if (!content) return details;
270
+ if (!expanded || !details || details === content) return content;
271
+ return `${content}\nDetails:\n${details}`;
248
272
  }
249
273
 
250
274
  export function outputLineCount(result: any): number {
@@ -267,23 +291,6 @@ function toolIcon(_name: string): string {
267
291
  return "●";
268
292
  }
269
293
 
270
- // Match the braille loader shown to the left of pi's "Working..." row.
271
- // Every frame is one cell wide, so tool titles remain horizontally stable.
272
- const WORKING_LOADER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
273
- const TOOL_PENDING_FRAMES: Record<string, string[]> = {
274
- read: WORKING_LOADER_FRAMES,
275
- bash: WORKING_LOADER_FRAMES,
276
- edit: WORKING_LOADER_FRAMES,
277
- write: WORKING_LOADER_FRAMES,
278
- find: WORKING_LOADER_FRAMES,
279
- grep: WORKING_LOADER_FRAMES,
280
- ls: WORKING_LOADER_FRAMES,
281
- };
282
-
283
- function animationFrame(frames: string[], intervalMs = 120): string {
284
- return frames[Math.floor(Date.now() / intervalMs) % frames.length] ?? frames[0] ?? "";
285
- }
286
-
287
294
  const activeAnimationContexts = new Set<any>();
288
295
  let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
289
296
 
@@ -308,7 +315,7 @@ function clearAllAnimations() {
308
315
  }
309
316
  }
310
317
 
311
- function scheduleAnimation(context: any, intervalMs = 80) {
318
+ function scheduleAnimation(context: any, intervalMs = TOOL_LOADING_INTERVAL_MS) {
312
319
  const state = (context.state ??= {});
313
320
  if (state.ccstyleAnimationScheduled) return;
314
321
  state.ccstyleAnimationScheduled = true;
@@ -326,8 +333,8 @@ function scheduleAnimation(context: any, intervalMs = 80) {
326
333
  }
327
334
  }
328
335
 
329
- function pendingIcon(name: string): string {
330
- return animationFrame(TOOL_PENDING_FRAMES[name] ?? [toolIcon(name)], 80);
336
+ function pendingIcon(_name: string): string {
337
+ return toolLoadingIcon();
331
338
  }
332
339
 
333
340
  type ToolVisualState = "pending" | "success" | "error";
@@ -434,7 +441,7 @@ const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view")
434
441
  * Expanded tool body with clear Input / Output sections (Grok Build–style).
435
442
  *
436
443
  * Visual frame:
437
- * ┌ Input [show more]
444
+ * ├ Input [show more]
438
445
  * │ path: src/a.ts
439
446
  * │
440
447
  * └ Output [show more]
@@ -538,7 +545,7 @@ export class ExpandedToolIoView {
538
545
 
539
546
  const theme = this.theme;
540
547
  const safeWidth = Math.max(1, Math.floor(width));
541
- const rail = " │ ";
548
+ const rail = " │ ";
542
549
  const railWidth = visibleWidth(rail);
543
550
  const bodyWidth = toolViewportWidth(safeWidth);
544
551
  const contentWidth = Math.max(1, bodyWidth - railWidth);
@@ -547,12 +554,12 @@ export class ExpandedToolIoView {
547
554
  this.truncated = { input: false, output: false };
548
555
 
549
556
  const pushHeader = (
550
- corner: "┌" | "└",
557
+ corner: "├" | "└",
551
558
  label: string,
552
559
  section: ToolIoSection,
553
560
  showMore: boolean,
554
561
  ) => {
555
- const mark = theme.fg("dim", ` ${corner} `);
562
+ const mark = theme.fg("dim", ` ${corner} `);
556
563
  const title = theme.fg(
557
564
  "accent",
558
565
  typeof theme.bold === "function" ? theme.bold(label) : label,
@@ -563,12 +570,13 @@ export class ExpandedToolIoView {
563
570
  lines.push(truncateToWidth(mark + title + more, safeWidth, ""));
564
571
  };
565
572
 
566
- const pushRailLine = (styledContent: string) => {
567
- lines.push(truncateToWidth(theme.fg("dim", rail) + styledContent, safeWidth, ""));
573
+ const pushRailLine = (styledContent: string, continued = true) => {
574
+ const prefix = continued ? rail : " ";
575
+ lines.push(truncateToWidth(theme.fg("dim", prefix) + styledContent, safeWidth, ""));
568
576
  };
569
577
 
570
578
  const pushBlankRail = () => {
571
- lines.push(truncateToWidth(theme.fg("dim", " │"), safeWidth, ""));
579
+ lines.push(truncateToWidth(theme.fg("dim", " │"), safeWidth, ""));
572
580
  };
573
581
 
574
582
  /** Style `key: value` input rows — dim keys, readable values. */
@@ -576,16 +584,16 @@ export class ExpandedToolIoView {
576
584
  const match = rawLine.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
577
585
  if (!match) return theme.fg("muted", rawLine);
578
586
  const [, key, sep, rest] = match;
579
- return theme.fg("dim", key + sep) + theme.fg("text", rest ?? "");
587
+ return theme.fg("dim", key + sep) + theme.fg("muted", rest ?? "");
580
588
  };
581
589
 
582
590
  const pushBody = (
583
591
  body: string,
584
- opts: { input?: boolean; limit: number },
592
+ opts: { input?: boolean; limit: number; continued?: boolean },
585
593
  ): boolean /* truncated */ => {
586
594
  const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
587
595
  if (!raw.trim()) {
588
- pushRailLine(theme.fg("dim", "(empty)"));
596
+ pushRailLine(theme.fg("dim", "(empty)"), opts.continued);
589
597
  return false;
590
598
  }
591
599
  const sourceLines = raw.split("\n");
@@ -600,11 +608,11 @@ export class ExpandedToolIoView {
600
608
  // theme/wrap measurements disagree slightly.
601
609
  const truncated = wrapped.length > opts.limit || sourceLines.length > opts.limit;
602
610
  const visible = truncated ? wrapped.slice(0, Math.min(opts.limit, wrapped.length)) : wrapped;
603
- for (const line of visible) pushRailLine(line);
611
+ for (const line of visible) pushRailLine(line, opts.continued);
604
612
  if (truncated) {
605
613
  const hidden = Math.max(0, wrapped.length - visible.length);
606
614
  if (hidden > 0) {
607
- pushRailLine(theme.fg("dim", `… +${hidden} more lines`));
615
+ pushRailLine(theme.fg("dim", `… +${hidden} more lines`), opts.continued);
608
616
  }
609
617
  }
610
618
  return truncated;
@@ -628,16 +636,20 @@ export class ExpandedToolIoView {
628
636
 
629
637
  if (hasInput) {
630
638
  this.truncated.input = inputWouldTruncate;
631
- pushHeader("┌", "Input", "input", inputWouldTruncate);
632
- pushBody(this.inputBody, { input: true, limit: this.maxInputLines });
639
+ pushHeader("├", "Input", "input", inputWouldTruncate);
640
+ pushBody(this.inputBody, {
641
+ input: true,
642
+ limit: this.maxInputLines,
643
+ continued: true,
644
+ });
633
645
  pushBlankRail();
634
646
  this.truncated.output = outputWouldTruncate;
635
647
  pushHeader("└", "Output", "output", outputWouldTruncate);
636
- pushBody(outputText, { limit: this.maxOutputLines });
648
+ pushBody(outputText, { limit: this.maxOutputLines, continued: false });
637
649
  } else {
638
650
  this.truncated.output = outputWouldTruncate;
639
- pushHeader("┌", "Output", "output", outputWouldTruncate);
640
- pushBody(outputText, { limit: this.maxOutputLines });
651
+ pushHeader("└", "Output", "output", outputWouldTruncate);
652
+ pushBody(outputText, { limit: this.maxOutputLines, continued: false });
641
653
  }
642
654
 
643
655
  this.cachedWidth = width;
@@ -682,7 +694,7 @@ function bodyExceedsLineLimit(
682
694
  if (asInput) {
683
695
  const match = source.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
684
696
  styled = match
685
- ? theme.fg("dim", match[1] + match[2]) + theme.fg("text", match[3] ?? "")
697
+ ? theme.fg("dim", match[1] + match[2]) + theme.fg("muted", match[3] ?? "")
686
698
  : theme.fg("muted", source);
687
699
  } else {
688
700
  styled = theme.fg(bodyColor, source);
@@ -695,16 +707,16 @@ function bodyExceedsLineLimit(
695
707
  }
696
708
 
697
709
  export function renderCollapsedToolResult(body: string, collapsedHint = ""): string {
698
- return ` ↳ ${body}${collapsedHint}`;
710
+ return ` ↳ ${body}${collapsedHint}`;
699
711
  }
700
712
 
701
713
  export function renderCollapsedToolResultToWidth(
702
714
  body: string,
703
715
  collapsedHint: string,
704
716
  width: number,
717
+ prefix = " ↳ ",
705
718
  ): string {
706
719
  const previewWidth = toolViewportWidth(width);
707
- const prefix = " ↳ ";
708
720
  const bodyWidth = Math.max(1, previewWidth - visibleWidth(prefix) - visibleWidth(collapsedHint));
709
721
  return truncateToWidth(
710
722
  prefix + middleTruncateToWidth(body, bodyWidth) + collapsedHint,
@@ -879,6 +891,7 @@ let assistantMessageActive = false;
879
891
  let scrollButtonSyncScheduled = false;
880
892
  let sessionRenderTimer: ReturnType<typeof setTimeout> | null = null;
881
893
  let hoveredToolCallId: string | null = null;
894
+ let hoveredToolGroup: ToolGroupComponent | null = null;
882
895
 
883
896
  function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
884
897
  const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
@@ -1004,7 +1017,7 @@ function fixedEditorContextScore(
1004
1017
  }
1005
1018
 
1006
1019
  /** Summary markers used by Pi and ccstyle; unlike the trailing hint, these survive truncation. */
1007
- const COLLAPSED_TOOL_SUMMARY = /^\s*(?:↳|└|⎿)/;
1020
+ const COLLAPSED_TOOL_SUMMARY = /^\s*(?:↳|└|⎿|●|✓|✗)/;
1008
1021
  /** fixedEditorContextScore max ≈ 100 + 2*(5+4+3+2); stop once we hit a near-perfect match. */
1009
1022
  const TOOL_CLICK_EARLY_EXIT_SCORE = 120;
1010
1023
 
@@ -1498,7 +1511,7 @@ function visibleMouseLine(tui: any, packet: SgrMousePacket): string {
1498
1511
  }
1499
1512
 
1500
1513
  function isCollapsedHintAtColumn(line: string, col: number): boolean {
1501
- const match = /(\([^()\n]* \/ click\))\s*$/.exec(line);
1514
+ const match = /(\([^()\n]* \/ click\)|click to show more)\s*$/.exec(line);
1502
1515
  if (!match?.[1] || col <= 0) return false;
1503
1516
  const start = visibleWidth(line.slice(0, match.index)) + 1;
1504
1517
  return col >= start && col < start + visibleWidth(match[1]);
@@ -1544,10 +1557,19 @@ function setHoveredToolIo(view: ExpandedToolIoView | null, section: ToolIoSectio
1544
1557
  return true;
1545
1558
  }
1546
1559
 
1560
+ function setHoveredToolGroup(group: ToolGroupComponent | null): boolean {
1561
+ if (group === hoveredToolGroup) return false;
1562
+ hoveredToolGroup?.setHintHovered(false);
1563
+ hoveredToolGroup = group;
1564
+ group?.setHintHovered(true);
1565
+ return true;
1566
+ }
1567
+
1547
1568
  function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1548
1569
  if ((packet.code & 32) === 0 || packet.final !== "M") return;
1549
1570
  const visibleLine = visibleMouseLine(tui, packet);
1550
1571
  let nextToolCallId: string | null = null;
1572
+ let nextGroup: ToolGroupComponent | null = null;
1551
1573
  let nextIoView: ExpandedToolIoView | null = null;
1552
1574
  let nextIoSection: ToolIoSection | null = null;
1553
1575
 
@@ -1557,6 +1579,7 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1557
1579
  ) {
1558
1580
  const hit = findToolAtScreenRow(tui, packet.row);
1559
1581
  nextToolCallId = hit && !hit.component.expanded ? hit.component.toolCallId : null;
1582
+ nextGroup = hit?.component instanceof ToolGroupComponent ? hit.component : null;
1560
1583
  } else if (visibleLine.includes(SHOW_MORE_LABEL)) {
1561
1584
  const hit = findToolAtScreenRow(tui, packet.row);
1562
1585
  const view = hit && hit.component.expanded ? resolveIoViewFromTool(hit.component) : null;
@@ -1570,11 +1593,13 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1570
1593
 
1571
1594
  const changed = nextToolCallId !== hoveredToolCallId;
1572
1595
  hoveredToolCallId = nextToolCallId;
1573
- if (setHoveredToolIo(nextIoView, nextIoSection) || changed) tui.requestRender?.();
1596
+ if (setHoveredToolIo(nextIoView, nextIoSection) || setHoveredToolGroup(nextGroup) || changed)
1597
+ tui.requestRender?.();
1574
1598
  }
1575
1599
 
1576
1600
  function isExpandedToolCardHit(hit: ToolRenderHit, width: number): boolean {
1577
1601
  if (!Boolean(hit.component.expanded)) return false;
1602
+ if (hit.component instanceof ToolGroupComponent) return true;
1578
1603
  const box = hit.component.contentBox;
1579
1604
  if (!box || !Array.isArray(hit.component.children) || !hit.component.children.includes(box)) {
1580
1605
  return false;
@@ -1588,11 +1613,11 @@ function isExpandedToolCardHit(hit: ToolRenderHit, width: number): boolean {
1588
1613
 
1589
1614
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
1590
1615
  const line = visibleMouseLine(tui, packet);
1591
- const collapsedHint = isCollapsedHintAtColumn(line, packet.col);
1592
1616
  const hasShowMore = line.includes(SHOW_MORE_LABEL);
1593
1617
 
1594
1618
  const hit = findToolAtScreenRow(tui, packet.row);
1595
1619
  if (!hit) return false;
1620
+ const collapsedHint = isCollapsedHintAtColumn(line, packet.col);
1596
1621
  const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1597
1622
  if (Boolean(hit.component.expanded)) {
1598
1623
  if (hasShowMore && tryOpenToolIoShowMore(tui, packet, hit)) return true;
@@ -1600,6 +1625,7 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
1600
1625
  const previousHeight = renderComponentTree(hit.component, width).length;
1601
1626
  hit.component.setExpanded(false);
1602
1627
  hoveredToolCallId = null;
1628
+ setHoveredToolGroup(null);
1603
1629
  setHoveredToolIo(null, null);
1604
1630
  hit.component.invalidate?.();
1605
1631
  const nextHeight = renderComponentTree(hit.component, width).length;
@@ -1611,6 +1637,7 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
1611
1637
 
1612
1638
  hit.component.setExpanded(true);
1613
1639
  hoveredToolCallId = null;
1640
+ setHoveredToolGroup(null);
1614
1641
  setHoveredToolIo(null, null);
1615
1642
  hit.component.invalidate?.();
1616
1643
  tui.requestRender?.();
@@ -1798,6 +1825,7 @@ function teardownToolMouseInteraction(): void {
1798
1825
  toolMouseInputUnsubscribe?.();
1799
1826
  toolMouseInputUnsubscribe = null;
1800
1827
  hoveredToolCallId = null;
1828
+ setHoveredToolGroup(null);
1801
1829
  setHoveredToolIo(null, null);
1802
1830
  try {
1803
1831
  toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
@@ -1877,16 +1905,26 @@ function scheduleSessionRender(refresh?: () => void): void {
1877
1905
  const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
1878
1906
  const ANSI_FG_RESET = "\x1b[39m";
1879
1907
 
1880
- function refreshCurrentTranscript(compactStyle: CompactStyleHooks, ctx?: any): void {
1908
+ function refreshCurrentTranscript(
1909
+ compactStyle: CompactStyleHooks,
1910
+ ctx?: any,
1911
+ toolGrouping?: ToolGroupingHooks,
1912
+ ): void {
1913
+ toolGrouping?.refresh();
1881
1914
  compactStyle.refresh();
1882
1915
  toolMouseTui?.requestRender?.(true);
1883
1916
  ctx?.ui?.requestRender?.(true);
1884
1917
  }
1885
1918
 
1886
- function applyStyleMode(mode: CompactStyleMode, ctx: any, compactStyle: CompactStyleHooks): void {
1919
+ function applyStyleMode(
1920
+ mode: CompactStyleMode,
1921
+ ctx: any,
1922
+ compactStyle: CompactStyleHooks,
1923
+ toolGrouping?: ToolGroupingHooks,
1924
+ ): void {
1887
1925
  config.mode = mode;
1888
1926
  saveConfig();
1889
- refreshCurrentTranscript(compactStyle, ctx);
1927
+ refreshCurrentTranscript(compactStyle, ctx, toolGrouping);
1890
1928
  ctx.ui.notify(`Claude Code style: ${mode}`, "info");
1891
1929
  }
1892
1930
 
@@ -2011,7 +2049,11 @@ function renderSectionTabBar(
2011
2049
  return truncateToWidth(pieces.join(""), Math.max(0, width));
2012
2050
  }
2013
2051
 
2014
- async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Promise<void> {
2052
+ async function showCcstylePanel(
2053
+ ctx: any,
2054
+ compactStyle: CompactStyleHooks,
2055
+ toolGrouping?: ToolGroupingHooks,
2056
+ ): Promise<void> {
2015
2057
  if (ctx?.mode !== "tui" || !ctx?.hasUI || typeof ctx.ui?.custom !== "function") {
2016
2058
  ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
2017
2059
  return;
@@ -2110,7 +2152,7 @@ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Prom
2110
2152
  switch (id) {
2111
2153
  case "mode":
2112
2154
  modeSetting.description = modeSettingDescription(value as CompactStyleMode);
2113
- applyStyleMode(value as CompactStyleMode, ctx, compactStyle);
2155
+ applyStyleMode(value as CompactStyleMode, ctx, compactStyle, toolGrouping);
2114
2156
  return;
2115
2157
  case "fixedEditorFeatures":
2116
2158
  config.fixedEditorFeatures = value === "on";
@@ -2287,21 +2329,101 @@ function singleLine(text: string) {
2287
2329
  };
2288
2330
  }
2289
2331
 
2290
- /** Return the most useful scalar argument without semantically truncating it. */
2291
- function toolCallArgument(args: any): { key: string; value: string } | undefined {
2292
- if (!args) return undefined;
2293
- const keys = Object.keys(args);
2294
- if (keys.length === 0) return undefined;
2295
- const preferred = ["query", "question", "command", "pattern", "name", "path", "url", "message"];
2296
- const key = preferred.find((candidate) => {
2297
- const val = args[candidate];
2298
- return val !== undefined && val !== null && typeof val !== "object";
2299
- });
2300
- const selected = key ?? keys[0];
2301
- const value = args[selected];
2302
- return value !== undefined && value !== null && typeof value !== "object"
2303
- ? { key: selected, value: oneLine(value, Infinity) }
2304
- : undefined;
2332
+ function insetComponent(component: any): any {
2333
+ return {
2334
+ render: (width: number) =>
2335
+ component.render(Math.max(1, width - 1)).map((line: string) => {
2336
+ const nestedMarker = line.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*)↳/, "$1 ↳");
2337
+ return ` ${nestedMarker}`;
2338
+ }),
2339
+ invalidate: () => component.invalidate?.(),
2340
+ };
2341
+ }
2342
+
2343
+ function humanizeToolLabel(label: string): string {
2344
+ return label
2345
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
2346
+ .replace(/[_-]+/g, " ")
2347
+ .replace(/\b\w/g, (char) => char.toUpperCase());
2348
+ }
2349
+
2350
+ function singleToolCallSummary(
2351
+ toolName: string,
2352
+ label: string,
2353
+ args: any,
2354
+ ): { main: string; detail: string } {
2355
+ const title = label === toolName ? humanizeToolLabel(label) : label;
2356
+ if (!args || typeof args !== "object") return { main: title, detail: "" };
2357
+ const name = toolName.toLowerCase();
2358
+ const value = (fallback: string, ...keys: string[]) => {
2359
+ const found = keys.map((key) => args[key]).find((item) => typeof item === "string" && item);
2360
+ return `${title} ${oneLine(found || fallback, Infinity)}`;
2361
+ };
2362
+ if (AGENT_FAMILY_TOOL_NAMES.has(toolName) && args.agent_id) {
2363
+ return { main: `${title} ${oneLine(args.agent_id, Infinity)}`, detail: "" };
2364
+ }
2365
+ if (name === "agent" || name === "agents") {
2366
+ return {
2367
+ main: value(name === "agent" ? "launch agent" : "launch agents", "description", "prompt"),
2368
+ detail: "",
2369
+ };
2370
+ }
2371
+ if (name === "skill") return { main: value("run skill", "name"), detail: "" };
2372
+ if (name === "enterplanmode" || name === "enter_plan_mode") {
2373
+ return { main: `${title} enable read-only planning`, detail: "" };
2374
+ }
2375
+ if (name === "exitplanmode" || name === "exit_plan_mode") {
2376
+ return { main: `${title} present plan`, detail: "" };
2377
+ }
2378
+ if (name === "taskcreate") return { main: value("create task", "subject"), detail: "" };
2379
+ if (name === "tasklist") return { main: `${title} task list`, detail: "" };
2380
+ if (name === "taskget" || name === "taskupdate") {
2381
+ return { main: value("task", "taskId", "task_id"), detail: "" };
2382
+ }
2383
+ if (name === "taskoutput" || name === "taskstop") {
2384
+ return { main: value("background task", "task_id", "taskId"), detail: "" };
2385
+ }
2386
+ if (name === "taskexecute") {
2387
+ const ids = Array.isArray(args.task_ids)
2388
+ ? args.task_ids
2389
+ : Array.isArray(args.taskIds)
2390
+ ? args.taskIds
2391
+ : [];
2392
+ const summary = ids.length
2393
+ ? `${ids[0]}${ids.length > 1 ? ` (+${ids.length - 1} tasks)` : ""}`
2394
+ : "start tasks";
2395
+ return { main: `${title} ${summary}`, detail: "" };
2396
+ }
2397
+ if (toolName === "read") {
2398
+ const details = [
2399
+ args.offset !== undefined ? `offset=${args.offset}` : "",
2400
+ args.limit !== undefined ? `limit=${args.limit}` : "",
2401
+ ].filter(Boolean);
2402
+ return {
2403
+ main: `${title}${args.path ? ` ${oneLine(args.path, Infinity)}` : ""}`,
2404
+ detail: details.length ? ` (${details.join(", ")})` : "",
2405
+ };
2406
+ }
2407
+ const preferred =
2408
+ args.path ??
2409
+ args.file_path ??
2410
+ args.command ??
2411
+ args.query ??
2412
+ args.question ??
2413
+ args.pattern ??
2414
+ args.url ??
2415
+ args.name ??
2416
+ args.tool_use_id ??
2417
+ args.toolCallId ??
2418
+ args.id ??
2419
+ args.message;
2420
+ return {
2421
+ main:
2422
+ preferred !== undefined && preferred !== null && typeof preferred !== "object"
2423
+ ? `${title} ${oneLine(preferred, Infinity)}`
2424
+ : title,
2425
+ detail: "",
2426
+ };
2305
2427
  }
2306
2428
 
2307
2429
  export function middleTruncateToWidth(text: string, width: number): string {
@@ -2330,6 +2452,74 @@ export function shouldRenderRichDiff(
2330
2452
  return mode === "on" && !isError && (toolName === "edit" || toolName === "write");
2331
2453
  }
2332
2454
 
2455
+ type ParsedTask = { id: string; status: string; subject: string };
2456
+
2457
+ function parseTaskList(text: string): ParsedTask[] {
2458
+ return text
2459
+ .split("\n")
2460
+ .map((line) => line.match(/^#(\d+) \[([^\]]+)] (.+)$/))
2461
+ .filter((match): match is RegExpMatchArray => Boolean(match))
2462
+ .map((match) => ({ id: match[1]!, status: match[2]!, subject: match[3]! }));
2463
+ }
2464
+
2465
+ function taskListSummary(tasks: ParsedTask[]): string {
2466
+ const counts = { pending: 0, in_progress: 0, completed: 0 };
2467
+ for (const task of tasks) {
2468
+ if (task.status in counts) counts[task.status as keyof typeof counts]++;
2469
+ }
2470
+ return [
2471
+ `${tasks.length} tasks`,
2472
+ counts.in_progress ? `${counts.in_progress} in progress` : "",
2473
+ counts.pending ? `${counts.pending} pending` : "",
2474
+ counts.completed ? `${counts.completed} completed` : "",
2475
+ ]
2476
+ .filter(Boolean)
2477
+ .join(" • ");
2478
+ }
2479
+
2480
+ function renderExpandedTaskResult(
2481
+ toolName: string,
2482
+ text: string,
2483
+ theme: any,
2484
+ isError: boolean,
2485
+ ): any | undefined {
2486
+ if (isError) return undefined;
2487
+ if (toolName === "TaskList") {
2488
+ const tasks = parseTaskList(text);
2489
+ if (!tasks.length) return undefined;
2490
+ const limit = Math.max(1, config.expandedPreviewMaxLines);
2491
+ const rows = tasks.slice(0, limit).map((task) => {
2492
+ const color =
2493
+ task.status === "completed"
2494
+ ? "success"
2495
+ : task.status === "in_progress"
2496
+ ? "warning"
2497
+ : "muted";
2498
+ return ` ${theme.fg("accent", `#${task.id}`)} ${theme.fg(color, task.status)} ${theme.fg("dim", task.subject)}`;
2499
+ });
2500
+ if (tasks.length > rows.length)
2501
+ rows.push(theme.fg("muted", ` … ${tasks.length - rows.length} more tasks`));
2502
+ return new Text(` ↳ ${theme.fg("muted", taskListSummary(tasks))}\n${rows.join("\n")}`, 0, 0);
2503
+ }
2504
+ const line = text.trim();
2505
+ if (!line || line.includes("\n")) return undefined;
2506
+ let formatted: string | undefined;
2507
+ let match: RegExpMatchArray | null;
2508
+ if (
2509
+ toolName === "TaskCreate" &&
2510
+ (match = line.match(/^Task #(\d+) created successfully: (.+)$/))
2511
+ ) {
2512
+ formatted = `${theme.fg("success", "Created task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
2513
+ } else if (toolName === "TaskUpdate" && (match = line.match(/^Updated task #(\d+) (.+)$/))) {
2514
+ formatted = `${theme.fg("success", "Updated task")} ${theme.fg("accent", `#${match[1]}`)} ${theme.fg("muted", match[2])}`;
2515
+ } else if (toolName === "TaskExecute") {
2516
+ formatted = `${theme.fg("success", "Started")} ${theme.fg("muted", line)}`;
2517
+ } else if (toolName === "TaskStop") {
2518
+ formatted = `${theme.fg("success", "Stopped")} ${theme.fg("muted", line)}`;
2519
+ }
2520
+ return formatted ? new Text(` ↳ ${formatted}`, 0, 0) : undefined;
2521
+ }
2522
+
2333
2523
  /** Wrap an arbitrary tool definition with ccstyle call/result rendering. */
2334
2524
  function createCcstyleTool(
2335
2525
  originalTool: any,
@@ -2337,7 +2527,7 @@ function createCcstyleTool(
2337
2527
  ): any {
2338
2528
  const toolName = originalTool.name;
2339
2529
  const label = isMcpToolDefinition(originalTool, toolName)
2340
- ? toolName
2530
+ ? humanizeMcpToolName(toolName)
2341
2531
  : originalTool.label || toolName;
2342
2532
 
2343
2533
  return {
@@ -2358,20 +2548,18 @@ function createCcstyleTool(
2358
2548
  visualState === "success"
2359
2549
  ? `${BRIGHT_GREEN}${rawIcon}${ANSI_FG_RESET}`
2360
2550
  : theme.fg(toolIconColor(context), rawIcon);
2361
-
2362
- const argument = toolCallArgument(args);
2551
+ const summary = singleToolCallSummary(toolName, label, args);
2363
2552
  let cachedWidth: number | undefined;
2364
2553
  let cachedLine: string | undefined;
2365
2554
  return {
2366
2555
  render(width: number) {
2367
2556
  if (cachedLine !== undefined && cachedWidth === width) return [cachedLine];
2368
- const callWidth = Math.max(0, toolViewportWidth(width) - visibleWidth(icon) - 1);
2369
- const argumentWidth = Math.max(1, callWidth - visibleWidth(`${label}()`));
2370
- const shown = argument ? middleTruncateToWidth(argument.value, argumentWidth) : undefined;
2371
- const call = shown === undefined ? label : `${label}(${shown})`;
2557
+ const viewportWidth = toolViewportWidth(width);
2558
+ const callWidth = Math.max(0, viewportWidth - visibleWidth(icon) - 2);
2559
+ const mainWidth = Math.max(0, callWidth - visibleWidth(summary.detail));
2372
2560
  cachedWidth = width;
2373
- cachedLine = `${icon} ${theme.fg("toolTitle", truncateToWidth(call, callWidth, ""))}`;
2374
- return [cachedLine];
2561
+ cachedLine = ` ${icon} ${theme.fg("toolTitle", middleTruncateToWidth(summary.main, mainWidth))}${theme.fg("muted", summary.detail)}`;
2562
+ return [truncateToWidth(cachedLine, viewportWidth, "")];
2375
2563
  },
2376
2564
  invalidate() {},
2377
2565
  };
@@ -2387,7 +2575,7 @@ function createCcstyleTool(
2387
2575
  }
2388
2576
 
2389
2577
  if (options?.isPartial) {
2390
- return new Text(theme.fg("muted", " ⎿ Pending…"), 0, 0);
2578
+ return new Text(theme.fg("muted", " ⎿ Pending…"), 0, 0);
2391
2579
  }
2392
2580
 
2393
2581
  const isError = options?.isError || context?.isError;
@@ -2405,17 +2593,31 @@ function createCcstyleTool(
2405
2593
  writeExecutionMetadata,
2406
2594
  getToolDisplayConfig,
2407
2595
  );
2408
- if (richResult) return richResult;
2596
+ if (richResult) return insetComponent(richResult);
2409
2597
  }
2410
2598
 
2411
- const text = textFromResult(result);
2599
+ const text = textFromResult(result, expanded);
2412
2600
  const args = context?.args;
2413
- const rendered = !expanded ? (text ? oneLine(text) : "Done") : text;
2414
-
2415
- const detailLineCount = outputLineCount(result) || countLines(formatToolInputArgs(args));
2416
- const hint =
2417
- !expanded && hasExpandableDetail(text, args) ? expandHint(theme, detailLineCount) : "";
2418
- const hoveredHint = hint ? expandHint(theme, detailLineCount, true) : "";
2601
+ if (expanded) {
2602
+ const taskResult = renderExpandedTaskResult(toolName, text, theme, Boolean(isError));
2603
+ if (taskResult) return taskResult;
2604
+ }
2605
+ const tasks = !isError && toolName === "TaskList" ? parseTaskList(text) : [];
2606
+ const outputLines = outputLineCount(result) || countLines(text);
2607
+ const lineWord = outputLines === 1 ? "line" : "lines";
2608
+ const action = toolName === "read" ? "loaded" : "returned";
2609
+ const rendered = tasks.length
2610
+ ? taskListSummary(tasks)
2611
+ : isError
2612
+ ? text
2613
+ ? oneLine(text)
2614
+ : "Failed"
2615
+ : outputLines
2616
+ ? `${outputLines} ${lineWord} ${action}`
2617
+ : "Done";
2618
+ const expandable = !expanded && (tasks.length > 0 || hasExpandableDetail(text, args));
2619
+ const hint = expandable ? theme.fg("muted", " • click to show more") : "";
2620
+ const hoveredHint = expandable ? theme.fg("text", " • click to show more") : "";
2419
2621
  if (expanded) {
2420
2622
  return renderExpandedToolResult(
2421
2623
  text || "",
@@ -2456,12 +2658,17 @@ function createCcstyleTool(
2456
2658
  * Apart from the write override used to capture pre-write content, renderers are
2457
2659
  * applied through ToolExecutionComponent. Patch its lookup once so tools use the
2458
2660
  * same compact fallback shell by default. Tools named in excludeRenderers keep
2459
- * their original renderer; subagent rendering remains protected.
2661
+ * their original renderer.
2460
2662
  */
2461
2663
  const GLOBAL_TOOL_RENDER_PATCH = Symbol.for("pi.ccstyle.global-tool-render-patch");
2462
2664
  const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-render-mode");
2463
2665
  const COMPONENT_TOOL_SELF_SHELL_MODE = Symbol.for("pi.ccstyle.component-tool-self-shell-mode");
2464
- const SUBAGENT_TOOL_NAMES = new Set(["Agent"]);
2666
+ const AGENT_FAMILY_TOOL_NAMES = new Set([
2667
+ "Agent",
2668
+ "Agents",
2669
+ "get_subagent_result",
2670
+ "steer_subagent",
2671
+ ]);
2465
2672
 
2466
2673
  type ToolRenderMethods = {
2467
2674
  hasRendererDefinition: (...args: any[]) => boolean;
@@ -2489,9 +2696,23 @@ type GlobalToolRenderPatch = {
2489
2696
  originalGetResultRenderer: ToolRenderMethods["getResultRenderer"];
2490
2697
  };
2491
2698
 
2492
- function isMcpToolDefinition(definition: any, toolName: string): boolean {
2493
- const label = typeof definition?.label === "string" ? definition.label : "";
2494
- return toolName === "mcp" || label === "MCP" || label.startsWith("MCP: ");
2699
+ export function isMcpToolDefinition(definition: any, toolName: string): boolean {
2700
+ const label = typeof definition?.label === "string" ? definition.label.trim() : "";
2701
+ if (/^MCP(?::|$)/i.test(label)) return true;
2702
+ if (toolName === "mcp" || /^mcp[_:-]|[_:-]mcp[_:-]/i.test(toolName)) return true;
2703
+ if (label) return false;
2704
+ const description = typeof definition?.description === "string" ? definition.description : "";
2705
+ return /\bModel Context Protocol\b/i.test(description);
2706
+ }
2707
+
2708
+ export function humanizeMcpToolName(toolName: string): string {
2709
+ const words = toolName
2710
+ .replace(/^mcp(?:[_:-]+)+/i, "")
2711
+ .split(/[_:-]+/)
2712
+ .filter(Boolean);
2713
+ return words.length
2714
+ ? words.map((word) => word[0]!.toUpperCase() + word.slice(1)).join(" ")
2715
+ : "MCP";
2495
2716
  }
2496
2717
 
2497
2718
  /** Return true when this tool must keep its original renderer. */
@@ -2501,7 +2722,6 @@ export function preservesOriginalRenderer(
2501
2722
  builtInToolDefinition?: any,
2502
2723
  excludeRenderers: readonly string[] = config.excludeRenderers,
2503
2724
  ): boolean {
2504
- if (SUBAGENT_TOOL_NAMES.has(toolName)) return true;
2505
2725
  if (!excludeRenderers.includes(toolName)) return false;
2506
2726
  return [extensionDefinition, builtInToolDefinition].some(
2507
2727
  (definition) =>
@@ -2542,16 +2762,9 @@ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch):
2542
2762
  return useCcstyle;
2543
2763
  }
2544
2764
 
2545
- function shouldUseSelfShell(component: any, patch: GlobalToolRenderPatch): boolean {
2546
- const definition = component.toolDefinition ?? component.builtInToolDefinition;
2547
- const toolName = String(component.toolName || definition?.name || "");
2548
- const useSelfShell =
2549
- patch.enabled() &&
2550
- SUBAGENT_TOOL_NAMES.has(toolName) &&
2551
- definition != null &&
2552
- definition.renderShell === undefined;
2553
- component[COMPONENT_TOOL_SELF_SHELL_MODE] = useSelfShell;
2554
- return useSelfShell;
2765
+ function shouldUseSelfShell(component: any, _patch: GlobalToolRenderPatch): boolean {
2766
+ component[COMPONENT_TOOL_SELF_SHELL_MODE] = false;
2767
+ return false;
2555
2768
  }
2556
2769
 
2557
2770
  function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
@@ -2785,6 +2998,7 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
2785
2998
  if (configOverride) config = normalizeConfig({ ...config, ...configOverride });
2786
2999
  const writeExecutionMetadata = installWriteOverride(pi, new WriteExecutionMetadataStore());
2787
3000
  const globalToolRendering = installGlobalToolRendering(writeExecutionMetadata);
3001
+ const toolGrouping = installToolGrouping(() => config.mode === "on");
2788
3002
  deactivateLegacyCompactionRendering();
2789
3003
  const compactStyle: CompactStyleHooks = installCompactStyle(pi, {
2790
3004
  getMode: () => config.mode,
@@ -2806,11 +3020,11 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
2806
3020
  handler: async (args, ctx) => {
2807
3021
  const arg = args.trim().toLowerCase();
2808
3022
  if (!arg || arg === "panel") {
2809
- await showCcstylePanel(ctx, compactStyle);
3023
+ await showCcstylePanel(ctx, compactStyle, toolGrouping);
2810
3024
  return;
2811
3025
  }
2812
3026
  if (arg === "on" || arg === "off" || arg === "compact") {
2813
- applyStyleMode(arg, ctx, compactStyle);
3027
+ applyStyleMode(arg, ctx, compactStyle, toolGrouping);
2814
3028
  return;
2815
3029
  }
2816
3030
  if (arg === "status") {
@@ -2822,6 +3036,7 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
2822
3036
  });
2823
3037
 
2824
3038
  pi.on("session_start", async (event, ctx) => {
3039
+ toolGrouping.setTheme(ctx.ui.theme);
2825
3040
  compactStyle?.onSessionStart(event, ctx);
2826
3041
  pendingScrollMessages = 0;
2827
3042
  assistantMessageActive = false;
@@ -2831,6 +3046,7 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
2831
3046
  });
2832
3047
 
2833
3048
  pi.on("session_compact", async (event, ctx) => {
3049
+ toolGrouping.setTheme(ctx.ui.theme);
2834
3050
  compactStyle?.onSessionCompact(event, ctx);
2835
3051
  // Compaction rebuilds the transcript without session_start. Rebind after
2836
3052
  // other TUI extensions may have replaced the root input dispatcher.
@@ -2864,6 +3080,7 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
2864
3080
  });
2865
3081
 
2866
3082
  pi.on("tool_execution_start", async (event, ctx) => {
3083
+ toolGrouping.setTheme(ctx.ui.theme);
2867
3084
  compactStyle?.onToolExecutionStart(event, ctx);
2868
3085
  });
2869
3086
 
@@ -2880,6 +3097,7 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
2880
3097
  writeExecutionMetadata.clear();
2881
3098
  teardownToolMouseInteraction();
2882
3099
  deactivateGlobalToolRendering(globalToolRendering);
3100
+ toolGrouping.shutdown();
2883
3101
  deactivateLegacyCompactionRendering();
2884
3102
  clearAllAnimations();
2885
3103
  });