pi-cc-extensions 0.8.29 → 0.8.31

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.
@@ -1017,7 +1017,7 @@ function fixedEditorContextScore(
1017
1017
  }
1018
1018
 
1019
1019
  /** Summary markers used by Pi and ccstyle; unlike the trailing hint, these survive truncation. */
1020
- const COLLAPSED_TOOL_SUMMARY = /^\s*(?:↳|└|⎿|●|✓|✗)/;
1020
+ const COLLAPSED_TOOL_SUMMARY = /^\s*(?:↳|└|⎿|●|✓|✗|…)/;
1021
1021
  /** fixedEditorContextScore max ≈ 100 + 2*(5+4+3+2); stop once we hit a near-perfect match. */
1022
1022
  const TOOL_CLICK_EARLY_EXIT_SCORE = 120;
1023
1023
 
@@ -1511,7 +1511,8 @@ function visibleMouseLine(tui: any, packet: SgrMousePacket): string {
1511
1511
  }
1512
1512
 
1513
1513
  function isCollapsedHintAtColumn(line: string, col: number): boolean {
1514
- const match = /(\([^()\n]* \/ click\)|click to show more)\s*$/.exec(line);
1514
+ // Rich diff hints place "click to show more" inside a trailing parenthesis.
1515
+ const match = /(\([^()\n]* \/ click\)|click to show more)(?=\)?\s*$)/.exec(line);
1515
1516
  if (!match?.[1] || col <= 0) return false;
1516
1517
  const start = visibleWidth(line.slice(0, match.index)) + 1;
1517
1518
  return col >= start && col < start + visibleWidth(match[1]);
@@ -2575,19 +2576,25 @@ function createCcstyleTool(
2575
2576
  }
2576
2577
 
2577
2578
  if (options?.isPartial) {
2578
- return new Text(theme.fg("muted", " ⎿ Pending…"), 0, 0);
2579
+ return new Text(theme.fg("muted", " ↳ Pending…"), 0, 0);
2579
2580
  }
2580
2581
 
2581
2582
  const isError = options?.isError || context?.isError;
2582
2583
  setToolVisualState(context, isError ? "error" : "success");
2583
2584
  const expanded = isToolExpanded(options, context);
2585
+ const toolCallId = context?.toolCallId;
2584
2586
  if (shouldRenderRichDiff(config.mode, toolName, Boolean(isError))) {
2585
2587
  // Pass getter so Diff indicator / wrap / limits update on the next paint
2586
2588
  // without recreating the tool result component.
2587
2589
  const richResult = renderRichToolResult(
2588
2590
  toolName,
2589
2591
  result,
2590
- { ...options, expanded },
2592
+ {
2593
+ ...options,
2594
+ expanded,
2595
+ // Live hover state for the collapsed hint row (muted → text on hover).
2596
+ isHovered: () => !!toolCallId && toolCallId === hoveredToolCallId,
2597
+ },
2591
2598
  theme,
2592
2599
  context,
2593
2600
  writeExecutionMetadata,
@@ -2629,7 +2636,6 @@ function createCcstyleTool(
2629
2636
  );
2630
2637
  }
2631
2638
  if (context?.state) context.state.ccstyleIoView = undefined;
2632
- const toolCallId = context?.toolCallId;
2633
2639
  let cachedWidth: number | undefined;
2634
2640
  let cachedLine: string | undefined;
2635
2641
  let cachedHoveredLine: string | undefined;
@@ -2997,13 +3003,26 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
2997
3003
  // The optional override keeps integration tests independent from the user's global config.
2998
3004
  if (configOverride) config = normalizeConfig({ ...config, ...configOverride });
2999
3005
  const writeExecutionMetadata = installWriteOverride(pi, new WriteExecutionMetadataStore());
3000
- const globalToolRendering = installGlobalToolRendering(writeExecutionMetadata);
3001
- const toolGrouping = installToolGrouping(() => config.mode === "on");
3002
- deactivateLegacyCompactionRendering();
3003
- const compactStyle: CompactStyleHooks = installCompactStyle(pi, {
3004
- getMode: () => config.mode,
3005
- getExcludeRenderers: () => config.excludeRenderers,
3006
- });
3006
+ let installation:
3007
+ | {
3008
+ globalToolRendering: GlobalToolRenderPatch;
3009
+ toolGrouping: ToolGroupingHooks;
3010
+ compactStyle: CompactStyleHooks;
3011
+ }
3012
+ | undefined;
3013
+ const ensureTuiInstallation = (ctx: any) => {
3014
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) return undefined;
3015
+ if (installation) return installation;
3016
+ const globalToolRendering = installGlobalToolRendering(writeExecutionMetadata);
3017
+ const toolGrouping = installToolGrouping(() => config.mode === "on");
3018
+ deactivateLegacyCompactionRendering();
3019
+ const compactStyle = installCompactStyle(pi, {
3020
+ getMode: () => config.mode,
3021
+ getExcludeRenderers: () => config.excludeRenderers,
3022
+ });
3023
+ installation = { globalToolRendering, toolGrouping, compactStyle };
3024
+ return installation;
3025
+ };
3007
3026
 
3008
3027
  pi.registerCommand("ccstyle", {
3009
3028
  description: "Configure Claude Code style, fixed editor, and rich diff options",
@@ -3020,11 +3039,15 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
3020
3039
  handler: async (args, ctx) => {
3021
3040
  const arg = args.trim().toLowerCase();
3022
3041
  if (!arg || arg === "panel") {
3023
- await showCcstylePanel(ctx, compactStyle, toolGrouping);
3042
+ const hooks = ensureTuiInstallation(ctx);
3043
+ if (hooks) await showCcstylePanel(ctx, hooks.compactStyle, hooks.toolGrouping);
3044
+ else ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
3024
3045
  return;
3025
3046
  }
3026
3047
  if (arg === "on" || arg === "off" || arg === "compact") {
3027
- applyStyleMode(arg, ctx, compactStyle, toolGrouping);
3048
+ const hooks = ensureTuiInstallation(ctx);
3049
+ if (hooks) applyStyleMode(arg, ctx, hooks.compactStyle, hooks.toolGrouping);
3050
+ else ctx.ui?.notify?.("/ccstyle requires TUI mode", "warning");
3028
3051
  return;
3029
3052
  }
3030
3053
  if (arg === "status") {
@@ -3036,69 +3059,81 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
3036
3059
  });
3037
3060
 
3038
3061
  pi.on("session_start", async (event, ctx) => {
3039
- toolGrouping.setTheme(ctx.ui.theme);
3040
- compactStyle?.onSessionStart(event, ctx);
3062
+ const hooks = ensureTuiInstallation(ctx);
3063
+ if (!hooks) return;
3064
+ hooks.toolGrouping.setTheme(ctx.ui.theme);
3065
+ hooks.compactStyle.onSessionStart(event, ctx);
3041
3066
  pendingScrollMessages = 0;
3042
3067
  assistantMessageActive = false;
3043
3068
  ctx.ui.setStatus("ccstyle", undefined);
3044
3069
  installToolMouseInteraction(ctx);
3045
- scheduleSessionRender(compactStyle.refresh);
3070
+ scheduleSessionRender(hooks.compactStyle.refresh);
3046
3071
  });
3047
3072
 
3048
3073
  pi.on("session_compact", async (event, ctx) => {
3049
- toolGrouping.setTheme(ctx.ui.theme);
3050
- compactStyle?.onSessionCompact(event, ctx);
3074
+ const hooks = ensureTuiInstallation(ctx);
3075
+ if (!hooks) return;
3076
+ hooks.toolGrouping.setTheme(ctx.ui.theme);
3077
+ hooks.compactStyle.onSessionCompact(event, ctx);
3051
3078
  // Compaction rebuilds the transcript without session_start. Rebind after
3052
3079
  // other TUI extensions may have replaced the root input dispatcher.
3053
3080
  installToolMouseInteraction(ctx);
3054
- scheduleSessionRender(compactStyle.refresh);
3081
+ scheduleSessionRender(hooks.compactStyle.refresh);
3055
3082
  });
3056
3083
 
3057
3084
  pi.on("message_start", async (event) => {
3058
- notePendingScrollMessage(event?.message?.role);
3085
+ if (installation) notePendingScrollMessage(event?.message?.role);
3059
3086
  });
3060
3087
 
3061
3088
  pi.on("message_update", async (event, ctx) => {
3062
- compactStyle?.onMessageUpdate(event, ctx);
3063
- if (event?.message?.role === "assistant") notePendingScrollMessage("assistant");
3089
+ installation?.compactStyle.onMessageUpdate(event, ctx);
3090
+ if (installation && event?.message?.role === "assistant") notePendingScrollMessage("assistant");
3064
3091
  });
3065
3092
 
3066
3093
  pi.on("message_end", async (event) => {
3067
- if (event?.message?.role === "assistant") assistantMessageActive = false;
3094
+ if (installation && event?.message?.role === "assistant") assistantMessageActive = false;
3068
3095
  });
3069
3096
 
3070
3097
  pi.on("agent_start", async (event, ctx) => {
3071
- compactStyle?.onAgentStart(event, ctx);
3098
+ installation?.compactStyle.onAgentStart(event, ctx);
3072
3099
  });
3073
3100
 
3074
3101
  pi.on("agent_end", async (event, ctx) => {
3075
- compactStyle?.onAgentEnd(event, ctx);
3102
+ installation?.compactStyle.onAgentEnd(event, ctx);
3076
3103
  });
3077
3104
 
3078
3105
  pi.on("turn_start", async (event, ctx) => {
3079
- compactStyle?.onTurnStart(event, ctx);
3106
+ installation?.compactStyle.onTurnStart(event, ctx);
3080
3107
  });
3081
3108
 
3082
3109
  pi.on("tool_execution_start", async (event, ctx) => {
3083
- toolGrouping.setTheme(ctx.ui.theme);
3084
- compactStyle?.onToolExecutionStart(event, ctx);
3110
+ installation?.toolGrouping.setTheme(ctx.ui.theme);
3111
+ installation?.compactStyle.onToolExecutionStart(event, ctx);
3085
3112
  });
3086
3113
 
3087
3114
  pi.on("tool_execution_update", async (event, ctx) => {
3088
- compactStyle?.onToolExecutionUpdate(event, ctx);
3115
+ installation?.compactStyle.onToolExecutionUpdate(event, ctx);
3089
3116
  });
3090
3117
 
3091
3118
  pi.on("tool_execution_end", async (event, ctx) => {
3092
- compactStyle?.onToolExecutionEnd(event, ctx);
3119
+ installation?.compactStyle.onToolExecutionEnd(event, ctx);
3093
3120
  });
3094
3121
 
3095
3122
  pi.on("session_shutdown", async (event, ctx) => {
3096
- compactStyle?.onSessionShutdown(event, ctx);
3097
3123
  writeExecutionMetadata.clear();
3124
+ const current = installation;
3125
+ if (
3126
+ !current ||
3127
+ (globalThis as any)[GLOBAL_TOOL_RENDER_PATCH] !== current.globalToolRendering ||
3128
+ !current.globalToolRendering.active
3129
+ )
3130
+ return;
3131
+ current.compactStyle.onSessionShutdown(event, ctx);
3098
3132
  teardownToolMouseInteraction();
3099
- deactivateGlobalToolRendering(globalToolRendering);
3100
- toolGrouping.shutdown();
3133
+ deactivateGlobalToolRendering(current.globalToolRendering);
3134
+ current.toolGrouping.shutdown();
3101
3135
  deactivateLegacyCompactionRendering();
3102
3136
  clearAllAnimations();
3137
+ installation = undefined;
3103
3138
  });
3104
3139
  }
@@ -7,12 +7,14 @@ import piAliases from "./pi-aliases.ts";
7
7
  import piStartupHeader from "./pi-startup-header.ts";
8
8
  import sessionReference from "./session-reference.ts";
9
9
  import subagentNotification from "./subagent-notification.ts";
10
+ import workingMessage from "./working-message.ts";
10
11
 
11
12
  export default function (pi: ExtensionAPI): void {
12
13
  piAliases(pi);
13
14
  piStartupHeader(pi);
14
15
  claudeCodeStyle(pi);
15
16
  subagentNotification(pi);
17
+ workingMessage(pi);
16
18
  context(pi);
17
19
  sessionReference(pi);
18
20
  agentAutocomplete(pi);
@@ -84,31 +84,38 @@ export function formatSubagentNotificationLines(lines: string[], width: number):
84
84
  }
85
85
 
86
86
  export default function subagentNotificationExtension(pi: ExtensionAPI): void {
87
- const prototype = CustomMessageComponent.prototype as any;
88
- const host = globalThis as any;
89
- const previous = host[PATCH_KEY] as Patch | undefined;
90
- if (previous) previous.active = false;
91
- const originalRender =
92
- previous && prototype.render === previous.installedRender
93
- ? previous.originalRender
94
- : prototype.render;
95
- const patch = {
96
- active: true,
97
- prototype,
98
- originalRender,
99
- installedRender: undefined as any,
100
- } satisfies Patch;
101
- patch.installedRender = function (this: any, width: number): string[] {
102
- const lines = originalRender.call(this, width);
103
- if (!patch.active || this?.message?.customType !== "subagent-notification") return lines;
104
- return formatSubagentNotificationLines(lines, width);
105
- };
106
- prototype.render = patch.installedRender;
107
- host[PATCH_KEY] = patch;
87
+ let patch: Patch | undefined;
88
+
89
+ pi.on("session_start", async (_event, ctx) => {
90
+ if (patch || ctx?.mode !== "tui" || !ctx?.hasUI) return;
91
+ const prototype = CustomMessageComponent.prototype as any;
92
+ const host = globalThis as any;
93
+ const previous = host[PATCH_KEY] as Patch | undefined;
94
+ if (previous) previous.active = false;
95
+ const originalRender =
96
+ previous && prototype.render === previous.installedRender
97
+ ? previous.originalRender
98
+ : prototype.render;
99
+ patch = {
100
+ active: true,
101
+ prototype,
102
+ originalRender,
103
+ installedRender: undefined as any,
104
+ };
105
+ const installed = patch;
106
+ installed.installedRender = function (this: any, width: number): string[] {
107
+ const lines = originalRender.call(this, width);
108
+ if (!installed.active || this?.message?.customType !== "subagent-notification") return lines;
109
+ return formatSubagentNotificationLines(lines, width);
110
+ };
111
+ prototype.render = installed.installedRender;
112
+ host[PATCH_KEY] = installed;
113
+ });
108
114
 
109
115
  pi.on("session_shutdown", async () => {
110
- if (!patch.active) return;
116
+ if (!patch?.active) return;
111
117
  patch.active = false;
112
- if (prototype.render === patch.installedRender) prototype.render = originalRender;
118
+ if (patch.prototype.render === patch.installedRender)
119
+ patch.prototype.render = patch.originalRender;
113
120
  });
114
121
  }
@@ -121,6 +121,8 @@ interface DiffRenderOptions {
121
121
  previousContent?: string;
122
122
  fileExistedBeforeWrite?: boolean;
123
123
  headerLabel?: string;
124
+ /** Live hover state for the collapsed hint row (muted → text on hover). */
125
+ isHovered?: () => boolean;
124
126
  invalidate?: () => void;
125
127
  }
126
128
 
@@ -2289,6 +2291,7 @@ function applyLineLimit(
2289
2291
  * remainder so the collapse hint still reflects total hidden content.
2290
2292
  */
2291
2293
  unprocessedLogicalRows = 0,
2294
+ hovered = false,
2292
2295
  ): string[] {
2293
2296
  const limit = resolveDiffDisplayLimit(expanded, maxCollapsedLines, maxExpandedLines);
2294
2297
  const safeUnprocessed = Math.max(0, unprocessedLogicalRows);
@@ -2319,7 +2322,10 @@ function applyLineLimit(
2319
2322
  return [
2320
2323
  ...shown.map((row) => clampDiffLineToWidth(row.text, width)),
2321
2324
  renderDiffSpacerLine(width),
2322
- clampDiffLineToWidth(theme.fg(expanded ? "warning" : "muted", hintText), width),
2325
+ clampDiffLineToWidth(
2326
+ theme.fg(expanded ? "warning" : hovered ? "text" : "muted", hintText),
2327
+ width,
2328
+ ),
2323
2329
  ];
2324
2330
  }
2325
2331
 
@@ -2403,6 +2409,7 @@ function createDiffRenderCache() {
2403
2409
  let cachedExpanded: boolean | undefined;
2404
2410
  let cachedMode: DiffPresentationMode | undefined;
2405
2411
  let cachedConfigKey: string | undefined;
2412
+ let cachedHovered: boolean | undefined;
2406
2413
  let cachedLines: string[] | undefined;
2407
2414
 
2408
2415
  return {
@@ -2411,13 +2418,15 @@ function createDiffRenderCache() {
2411
2418
  expanded: boolean,
2412
2419
  mode: DiffPresentationMode,
2413
2420
  configKey: string,
2421
+ hovered: boolean,
2414
2422
  ): string[] | undefined {
2415
2423
  if (
2416
2424
  cachedLines &&
2417
2425
  cachedWidth === width &&
2418
2426
  cachedExpanded === expanded &&
2419
2427
  cachedMode === mode &&
2420
- cachedConfigKey === configKey
2428
+ cachedConfigKey === configKey &&
2429
+ cachedHovered === hovered
2421
2430
  ) {
2422
2431
  return cachedLines;
2423
2432
  }
@@ -2428,12 +2437,14 @@ function createDiffRenderCache() {
2428
2437
  expanded: boolean,
2429
2438
  mode: DiffPresentationMode,
2430
2439
  configKey: string,
2440
+ hovered: boolean,
2431
2441
  lines: string[],
2432
2442
  ): string[] {
2433
2443
  cachedWidth = width;
2434
2444
  cachedExpanded = expanded;
2435
2445
  cachedMode = mode;
2436
2446
  cachedConfigKey = configKey;
2447
+ cachedHovered = hovered;
2437
2448
  cachedLines = lines;
2438
2449
  return lines;
2439
2450
  },
@@ -2442,6 +2453,7 @@ function createDiffRenderCache() {
2442
2453
  cachedExpanded = undefined;
2443
2454
  cachedMode = undefined;
2444
2455
  cachedConfigKey = undefined;
2456
+ cachedHovered = undefined;
2445
2457
  cachedLines = undefined;
2446
2458
  },
2447
2459
  };
@@ -2500,7 +2512,8 @@ export function renderEditDiffResult(
2500
2512
  const configKey = displayConfigCacheKey(live);
2501
2513
  const safeWidth = normalizeDiffRenderWidth(width);
2502
2514
  const mode = resolveDiffPresentationMode(live, safeWidth, canRenderSplitLayout(safeWidth));
2503
- const cached = cache.get(safeWidth, options.expanded, mode, configKey);
2515
+ const hovered = options.isHovered?.() ?? false;
2516
+ const cached = cache.get(safeWidth, options.expanded, mode, configKey, hovered);
2504
2517
  if (cached) {
2505
2518
  return cached;
2506
2519
  }
@@ -2511,6 +2524,7 @@ export function renderEditDiffResult(
2511
2524
  options.expanded,
2512
2525
  mode,
2513
2526
  configKey,
2527
+ hovered,
2514
2528
  clampDiffLinesToWidth(
2515
2529
  renderSingleDiffRow(
2516
2530
  buildDiffSummaryText(parsed.stats, safeWidth),
@@ -2565,6 +2579,7 @@ export function renderEditDiffResult(
2565
2579
  parsed.stats.hunks,
2566
2580
  theme,
2567
2581
  unprocessedLogicalRows,
2582
+ hovered,
2568
2583
  );
2569
2584
  const frame = renderDiffFrameLine(safeWidth, theme);
2570
2585
  const renderedLines =
@@ -2573,7 +2588,7 @@ export function renderEditDiffResult(
2573
2588
  : [...headerRows.map((row) => row.text), ...bodyWithLimit];
2574
2589
 
2575
2590
  const clampedLines = clampDiffLinesToWidth(renderedLines, safeWidth);
2576
- return cache.set(safeWidth, options.expanded, mode, configKey, clampedLines);
2591
+ return cache.set(safeWidth, options.expanded, mode, configKey, hovered, clampedLines);
2577
2592
  },
2578
2593
  invalidate: cache.invalidate,
2579
2594
  };
@@ -2893,7 +2908,8 @@ export function renderWriteDiffResult(
2893
2908
  : resolvedMode === "split"
2894
2909
  ? "unified"
2895
2910
  : resolvedMode;
2896
- const cached = cache.get(safeWidth, options.expanded, mode, configKey);
2911
+ const hovered = options.isHovered?.() ?? false;
2912
+ const cached = cache.get(safeWidth, options.expanded, mode, configKey, hovered);
2897
2913
  if (cached) {
2898
2914
  return cached;
2899
2915
  }
@@ -2914,6 +2930,7 @@ export function renderWriteDiffResult(
2914
2930
  options.expanded,
2915
2931
  mode,
2916
2932
  configKey,
2933
+ hovered,
2917
2934
  clampDiffLinesToWidth(
2918
2935
  [header, ...renderWriteOverwriteGuardRows(overwriteGuard, safeWidth, theme)],
2919
2936
  safeWidth,
@@ -2939,6 +2956,7 @@ export function renderWriteDiffResult(
2939
2956
  options.expanded,
2940
2957
  mode,
2941
2958
  configKey,
2959
+ hovered,
2942
2960
  clampDiffLinesToWidth(summaryRows, safeWidth),
2943
2961
  );
2944
2962
  }
@@ -2989,12 +3007,13 @@ export function renderWriteDiffResult(
2989
3007
  data.hunkCount,
2990
3008
  theme,
2991
3009
  unprocessedLogicalRows,
3010
+ hovered,
2992
3011
  );
2993
3012
  const frame = renderDiffFrameLine(safeWidth, theme);
2994
3013
  const renderedLines =
2995
3014
  mode === "unified" ? [header, frame, ...bodyWithLimit, frame] : [header, ...bodyWithLimit];
2996
3015
  const finalLines = clampDiffLinesToWidth(renderedLines, safeWidth);
2997
- return cache.set(safeWidth, options.expanded, mode, configKey, finalLines);
3016
+ return cache.set(safeWidth, options.expanded, mode, configKey, hovered, finalLines);
2998
3017
  },
2999
3018
  invalidate: cache.invalidate,
3000
3019
  };
@@ -47,7 +47,12 @@ export function renderRichToolResult(
47
47
  if (toolName === "edit") {
48
48
  return renderEditDiffResult(
49
49
  result?.details,
50
- { expanded, filePath, invalidate: () => context?.invalidate?.() },
50
+ {
51
+ expanded,
52
+ filePath,
53
+ isHovered: options?.isHovered,
54
+ invalidate: () => context?.invalidate?.(),
55
+ },
51
56
  displayConfig,
52
57
  theme,
53
58
  resultText(result),
@@ -69,6 +74,7 @@ export function renderRichToolResult(
69
74
  filePath,
70
75
  previousContent: metadata.previousContent,
71
76
  fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
77
+ isHovered: options?.isHovered,
72
78
  invalidate: () => context?.invalidate?.(),
73
79
  },
74
80
  displayConfig,
@@ -71,7 +71,7 @@ export function buildCollapsedDiffHintText(
71
71
  : undefined;
72
72
 
73
73
  const candidates = [
74
- `… (${[remainingText, hiddenHunksText, "Ctrl+O to expand"].filter(Boolean).join(" • ")})`,
74
+ `… (${[remainingText, hiddenHunksText, "click to show more"].filter(Boolean).join(" • ")})`,
75
75
  `… (${[remainingText, hiddenHunksText].filter(Boolean).join(" • ")})`,
76
76
  `… (${[shortRemainingText, shortHiddenHunksText].filter(Boolean).join(" • ")})`,
77
77
  options.hiddenHunks > 0
@@ -1,5 +1,5 @@
1
1
  import { AssistantMessageComponent, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
2
- import { Container, Spacer, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { Container, Spacer, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "./tool-loading-icon.ts";
4
4
  import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
5
5
 
@@ -94,6 +94,42 @@ function stripLeadingStatusIcon(line: string): string {
94
94
  );
95
95
  }
96
96
 
97
+ function stripBackgroundAnsi(line: string): string {
98
+ return line.replace(/\x1b\[(?:4[0-9]|10[0-7]|48(?:(?:;|:)[0-9]+)+|49)m/g, "");
99
+ }
100
+
101
+ function stripLeadingSpaces(line: string, count: number): string {
102
+ let offset = 0;
103
+ let removed = 0;
104
+ let ansi = "";
105
+ while (offset < line.length) {
106
+ const control = line.slice(offset).match(/^\x1b\[[0-?]*[ -/]*[@-~]/)?.[0];
107
+ if (control) {
108
+ ansi += control;
109
+ offset += control.length;
110
+ continue;
111
+ }
112
+ if (removed < count && line[offset] === " ") {
113
+ removed++;
114
+ offset++;
115
+ continue;
116
+ }
117
+ break;
118
+ }
119
+ return ansi + line.slice(offset);
120
+ }
121
+
122
+ function paddedBackgroundRow(theme: any, slot: string, content: string, width: number): string {
123
+ const innerWidth = Math.max(0, width - 2);
124
+ const clipped = truncateToWidth(stripBackgroundAnsi(content), innerWidth, "");
125
+ const row = ` ${clipped}${" ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `;
126
+ if (typeof theme?.bg !== "function") return row;
127
+ const bgAnsi =
128
+ theme.getBgAnsi?.(slot) ?? theme.bg(slot, "").match(/^\x1b\[[0-?]*[ -/]*[@-~]/)?.[0] ?? "";
129
+ const stable = bgAnsi ? row.replace(/\x1b\[(?:0)?m/g, (reset) => reset + bgAnsi) : row;
130
+ return theme.bg(slot, stable);
131
+ }
132
+
97
133
  function oneLine(value: unknown, max = 96): string {
98
134
  const text = sanitizeToolResultText(String(value ?? ""), 4096)
99
135
  .replace(/\s+/g, " ")
@@ -275,12 +311,13 @@ export class ToolGroupComponent extends Container {
275
311
  const lines = [
276
312
  "",
277
313
  truncateToWidth(
278
- ` ${fg(overallColor, statusIcon(overall))} ${label}: ${countText}${nameList} ${hint}`,
314
+ ` ${fg(overallColor, "●")} ${label}: ${countText}${nameList} ${hint}`,
279
315
  width,
280
316
  "…",
281
317
  ),
282
318
  ];
283
319
  const total = this.children.length;
320
+ const expandedLines: string[] = [];
284
321
  for (let index = 0; index < total; index++) {
285
322
  const tool = this.children[index];
286
323
  const toolStatus = status(tool);
@@ -291,14 +328,14 @@ export class ToolGroupComponent extends Container {
291
328
  const summary = toolSummary(tool);
292
329
  lines.push(
293
330
  truncateToWidth(
294
- ` ${fg("dim", branch)} ${fg(color, "●")} ${fg("toolTitle", summary.main)}${fg("dim", summary.detail)}`,
331
+ ` ${fg("dim", branch)} ${fg(color, statusIcon(toolStatus))} ${fg("toolTitle", summary.main)}${fg("dim", summary.detail)}`,
295
332
  width,
296
333
  "…",
297
334
  ),
298
335
  );
299
336
  continue;
300
337
  }
301
- const rendered = visibleLines(tool.render(Math.max(1, width - 5)));
338
+ const rendered = visibleLines(tool.render(Math.max(1, width - 2)));
302
339
  if (rendered.length) {
303
340
  rendered[0] = stripLeadingStatusIcon(rendered[0])
304
341
  .replace(/^ +/, "")
@@ -306,14 +343,27 @@ export class ToolGroupComponent extends Container {
306
343
  }
307
344
  const childLines = rendered.length ? rendered : [toolSummary(tool).main];
308
345
  for (let lineIndex = 0; lineIndex < childLines.length; lineIndex++) {
346
+ const content =
347
+ lineIndex === 0 ? childLines[lineIndex] : stripLeadingSpaces(childLines[lineIndex], 2);
309
348
  const prefix =
310
349
  lineIndex === 0
311
- ? ` ${fg("dim", branch)} ${fg(color, "●")} `
312
- : ` ${fg("dim", continuation)} `;
313
- lines.push(truncateToWidth(prefix + childLines[lineIndex], width, ""));
350
+ ? `${fg("dim", branch)} ${fg(color, statusIcon(toolStatus))} `
351
+ : fg("dim", continuation);
352
+ expandedLines.push(prefix + content);
353
+ }
354
+ }
355
+ if (this.expanded) {
356
+ const backgroundSlot =
357
+ overall === "error"
358
+ ? "toolErrorBg"
359
+ : overall === "pending"
360
+ ? "toolPendingBg"
361
+ : "toolSuccessBg";
362
+ for (const line of expandedLines) {
363
+ lines.push(paddedBackgroundRow(theme, backgroundSlot, line, width));
314
364
  }
365
+ lines.push(paddedBackgroundRow(theme, backgroundSlot, "", width));
315
366
  }
316
- lines.push("");
317
367
  return lines;
318
368
  }
319
369
  }
@@ -0,0 +1,213 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const REFRESH_INTERVAL_MS = 1_000;
4
+ /** Elapsed time is only shown once the turn has run this long. */
5
+ const SHOW_TIMER_AFTER_MS = 3_000;
6
+
7
+ function formatDuration(ms: number): string {
8
+ const totalSec = Math.floor(ms / 1000);
9
+ const hours = Math.floor(totalSec / 3600);
10
+ const minutes = Math.floor((totalSec % 3600) / 60);
11
+ const seconds = totalSec % 60;
12
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
13
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
14
+ return `${seconds}s`;
15
+ }
16
+
17
+ function formatCount(value: number): string {
18
+ return new Intl.NumberFormat("en-US").format(value);
19
+ }
20
+
21
+ function estimateTextLength(message: any): number {
22
+ if (!Array.isArray(message?.content)) return 0;
23
+ return message.content.reduce(
24
+ (sum: number, block: any) =>
25
+ sum + (block?.type === "text" && typeof block.text === "string" ? block.text.length : 0),
26
+ 0,
27
+ );
28
+ }
29
+
30
+ function textBlockLengths(message: any): number[] {
31
+ if (!Array.isArray(message?.content)) return [];
32
+ const lengths: number[] = [];
33
+ for (let index = 0; index < message.content.length; index++) {
34
+ const block = message.content[index];
35
+ if (block?.type === "text" && typeof block.text === "string") {
36
+ lengths[index] = block.text.length;
37
+ }
38
+ }
39
+ return lengths;
40
+ }
41
+
42
+ function outputUsage(message: any): number {
43
+ const value = Number(message?.usage?.output);
44
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
45
+ }
46
+
47
+ /**
48
+ * Extend Pi's footer working row while preserving its spinner and "Working...":
49
+ * `⠋ Working... (↓ 1,234 tokens · 12s)`
50
+ *
51
+ * Live tokens use the same chars/4 estimate as pi-claude-code-ui, then switch to
52
+ * provider `usage.output` whenever the stream exposes an actual count.
53
+ */
54
+ export default function (pi: ExtensionAPI): void {
55
+ let turnActive = false;
56
+ let agentStartTime = 0;
57
+ let turnStartTime = 0;
58
+ let responseLength = 0;
59
+ let responseTextBlockLengths: number[] = [];
60
+ let providerOutputTokens = 0;
61
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
62
+ let lastMessage: string | null = null;
63
+ let activeCtx: { ui: any; hasUI: boolean } | null = null;
64
+
65
+ function tokenCount(): number {
66
+ return providerOutputTokens || Math.max(0, Math.round(responseLength / 4));
67
+ }
68
+
69
+ function setTextBlockLength(index: number, length: number): void {
70
+ const previous = responseTextBlockLengths[index] ?? 0;
71
+ responseTextBlockLengths[index] = Math.max(0, length);
72
+ responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
73
+ }
74
+
75
+ function resetResponseTracking(message?: any): void {
76
+ responseTextBlockLengths = message ? textBlockLengths(message) : [];
77
+ responseLength = message ? estimateTextLength(message) : 0;
78
+ providerOutputTokens = message ? outputUsage(message) : 0;
79
+ }
80
+
81
+ function updateProviderUsage(message: any): void {
82
+ const output = outputUsage(message);
83
+ if (output > 0) providerOutputTokens = output;
84
+ }
85
+
86
+ function buildWorkingMessage(): string {
87
+ const elapsed = Date.now() - (agentStartTime || turnStartTime);
88
+ const tokens = tokenCount();
89
+ const parts: string[] = [];
90
+ if (tokens > 0) parts.push(`↓ ${formatCount(tokens)} tokens`);
91
+ if (elapsed >= SHOW_TIMER_AFTER_MS || tokens > 0) parts.push(formatDuration(elapsed));
92
+ return parts.length ? `Working... (${parts.join(" · ")})` : "";
93
+ }
94
+
95
+ function restoreDefaultWorkingMessage(): void {
96
+ lastMessage = null;
97
+ if (!activeCtx?.hasUI) return;
98
+ try {
99
+ activeCtx.ui.setWorkingMessage();
100
+ } catch {
101
+ // Noop when the TUI is unavailable.
102
+ }
103
+ }
104
+
105
+ function syncWorkingMessage(force = false): void {
106
+ if (!activeCtx?.hasUI) return;
107
+ const next = buildWorkingMessage();
108
+ if (!next) {
109
+ if (force) restoreDefaultWorkingMessage();
110
+ return;
111
+ }
112
+ if (!force && next === lastMessage) return;
113
+ lastMessage = next;
114
+ try {
115
+ activeCtx.ui.setWorkingMessage(next);
116
+ } catch {
117
+ // Noop when the TUI is unavailable.
118
+ }
119
+ }
120
+
121
+ function scheduleRefreshTick(): void {
122
+ if (!turnActive || refreshTimer) return;
123
+ refreshTimer = setTimeout(() => {
124
+ refreshTimer = null;
125
+ syncWorkingMessage();
126
+ scheduleRefreshTick();
127
+ }, REFRESH_INTERVAL_MS);
128
+ refreshTimer.unref?.();
129
+ }
130
+
131
+ function stopRefreshLoop(): void {
132
+ if (!refreshTimer) return;
133
+ clearTimeout(refreshTimer);
134
+ refreshTimer = null;
135
+ }
136
+
137
+ function clearDisplay(): void {
138
+ stopRefreshLoop();
139
+ agentStartTime = 0;
140
+ turnStartTime = 0;
141
+ resetResponseTracking();
142
+ restoreDefaultWorkingMessage();
143
+ }
144
+
145
+ pi.on("before_agent_start", async () => {
146
+ if (!agentStartTime) agentStartTime = Date.now();
147
+ });
148
+
149
+ pi.on("turn_start", async (_event, ctx) => {
150
+ turnActive = true;
151
+ activeCtx = ctx;
152
+ turnStartTime = Date.now();
153
+ if (!agentStartTime) agentStartTime = turnStartTime;
154
+ resetResponseTracking();
155
+ syncWorkingMessage(true);
156
+ scheduleRefreshTick();
157
+ });
158
+
159
+ pi.on("message_update", async (event, ctx) => {
160
+ activeCtx = ctx;
161
+ const evt = event?.assistantMessageEvent;
162
+ if (!evt) return;
163
+
164
+ if (evt.type === "start") {
165
+ resetResponseTracking(evt.partial);
166
+ } else if (evt.type === "text_start") {
167
+ setTextBlockLength(evt.contentIndex, 0);
168
+ updateProviderUsage(evt.partial);
169
+ } else if (evt.type === "text_delta") {
170
+ setTextBlockLength(
171
+ evt.contentIndex,
172
+ (responseTextBlockLengths[evt.contentIndex] ?? 0) +
173
+ (typeof evt.delta === "string" ? evt.delta.length : 0),
174
+ );
175
+ updateProviderUsage(evt.partial);
176
+ } else if (evt.type === "text_end") {
177
+ setTextBlockLength(
178
+ evt.contentIndex,
179
+ typeof evt.content === "string" ? evt.content.length : 0,
180
+ );
181
+ updateProviderUsage(evt.partial);
182
+ } else if (evt.type === "done") {
183
+ resetResponseTracking(evt.message);
184
+ } else if (evt.type === "error") {
185
+ resetResponseTracking(evt.error);
186
+ } else {
187
+ updateProviderUsage(evt.partial);
188
+ }
189
+
190
+ syncWorkingMessage();
191
+ scheduleRefreshTick();
192
+ });
193
+
194
+ pi.on("turn_end", async (_event, ctx) => {
195
+ turnActive = false;
196
+ activeCtx = ctx;
197
+ stopRefreshLoop();
198
+ resetResponseTracking();
199
+ // No completion message: return immediately to Pi's default idle state.
200
+ restoreDefaultWorkingMessage();
201
+ });
202
+
203
+ pi.on("agent_end", async () => {
204
+ turnActive = false;
205
+ clearDisplay();
206
+ });
207
+
208
+ pi.on("session_shutdown", async () => {
209
+ turnActive = false;
210
+ clearDisplay();
211
+ activeCtx = null;
212
+ });
213
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.29",
3
+ "version": "0.8.31",
4
4
  "description": "A Pi productivity suite with Claude Code-style UI, structured questionnaires, context inspection, and agent/session references",
5
5
  "type": "module",
6
6
  "keywords": [