pi-cc-extensions 0.4.0 → 0.5.1

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.
@@ -301,6 +301,195 @@ function expandHint(theme: any): string {
301
301
  return `${theme.fg("muted", " (")}${keyHint("app.tools.expand", "expand")}${theme.fg("muted", " / click)")}`;
302
302
  }
303
303
 
304
+ type SgrMousePacket = {
305
+ code: number;
306
+ col: number;
307
+ row: number;
308
+ final: "M" | "m";
309
+ };
310
+
311
+ type ToolRenderHit = {
312
+ component: any;
313
+ start: number;
314
+ end: number;
315
+ };
316
+
317
+ const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
318
+ const TOOL_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
319
+ const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l";
320
+ let toolMouseTui: any = null;
321
+ let toolMouseUi: any = null;
322
+ let toolMouseInputUnsubscribe: (() => void) | null = null;
323
+
324
+ function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
325
+ const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
326
+ const packets: SgrMousePacket[] = [];
327
+ let offset = 0;
328
+
329
+ for (const match of data.matchAll(pattern)) {
330
+ if (match.index !== offset) return null;
331
+ offset = match.index + match[0].length;
332
+ packets.push({
333
+ code: Number(match[1]),
334
+ col: Number(match[2]),
335
+ row: Number(match[3]),
336
+ final: match[4] as "M" | "m",
337
+ });
338
+ }
339
+
340
+ return packets.length > 0 && offset === data.length ? packets : null;
341
+ }
342
+
343
+ function isSgrLeftPress(packet: SgrMousePacket): boolean {
344
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
345
+ return packet.final === "M" && baseButton === 0 && (packet.code & 32) === 0;
346
+ }
347
+
348
+ function stripTerminalSequences(value: string): string {
349
+ return value
350
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
351
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
352
+ .replace(/\s+/g, " ")
353
+ .trim();
354
+ }
355
+
356
+ function isToolExecutionComponent(value: any): boolean {
357
+ return Boolean(
358
+ value
359
+ && typeof value === "object"
360
+ && typeof value.toolCallId === "string"
361
+ && typeof value.setExpanded === "function"
362
+ && typeof value.render === "function",
363
+ );
364
+ }
365
+
366
+ function renderedLineCount(component: any, width: number, cache: WeakMap<object, number>): number {
367
+ if (!component || typeof component !== "object") return 0;
368
+ const cached = cache.get(component);
369
+ if (cached !== undefined) return cached;
370
+
371
+ let count = 0;
372
+ try {
373
+ const lines = component.render(width);
374
+ count = Array.isArray(lines) ? lines.length : 0;
375
+ } catch {
376
+ count = 0;
377
+ }
378
+ cache.set(component, count);
379
+ return count;
380
+ }
381
+
382
+ function collectToolRenderHits(
383
+ component: any,
384
+ start: number,
385
+ width: number,
386
+ hits: ToolRenderHit[],
387
+ cache: WeakMap<object, number>,
388
+ seen: Set<object>,
389
+ ): number {
390
+ if (!component || typeof component !== "object" || seen.has(component)) return start;
391
+ seen.add(component);
392
+
393
+ const count = renderedLineCount(component, width, cache);
394
+ if (isToolExecutionComponent(component)) {
395
+ if (count > 0) hits.push({ component, start, end: start + count });
396
+ return start + count;
397
+ }
398
+
399
+ let childStart = start;
400
+ if (Array.isArray(component.children)) {
401
+ for (const child of component.children) {
402
+ collectToolRenderHits(child, childStart, width, hits, cache, seen);
403
+ childStart += renderedLineCount(child, width, cache);
404
+ }
405
+ }
406
+ return start + count;
407
+ }
408
+
409
+ function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null {
410
+ const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
411
+ const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
412
+ const bufferRow = viewportTop + screenRow - 1;
413
+ if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
414
+
415
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
416
+ const hits: ToolRenderHit[] = [];
417
+ const cache = new WeakMap<object, number>();
418
+ collectToolRenderHits(tui, 0, width, hits, cache, new Set<object>());
419
+
420
+ const clickedLine = stripTerminalSequences(String(previousLines[bufferRow] ?? ""));
421
+ for (const hit of hits) {
422
+ if (bufferRow < hit.start || bufferRow >= hit.end) continue;
423
+ const component = hit.component;
424
+ const expanded = Boolean(component.expanded);
425
+ // Match Pi's old click contract: collapsed results expose a click hint;
426
+ // expanded results can be collapsed by clicking any rendered line.
427
+ if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
428
+ return hit;
429
+ }
430
+ return null;
431
+ }
432
+
433
+ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
434
+ const hit = findToolAtScreenRow(tui, packet.row);
435
+ if (!hit) return false;
436
+
437
+ const nextExpanded = !Boolean(hit.component.expanded);
438
+ hit.component.setExpanded(nextExpanded);
439
+ hit.component.invalidate?.();
440
+ tui.requestRender?.();
441
+ return true;
442
+ }
443
+
444
+ function handleToolMouseInput(data: string): { consume: true } | undefined {
445
+ if (!toolMouseTui) return undefined;
446
+ const packets = parseSgrMousePackets(data);
447
+ if (!packets) return undefined;
448
+
449
+ let consumed = false;
450
+ for (const packet of packets) {
451
+ if (isSgrLeftPress(packet) && toggleToolAtMouseClick(toolMouseTui, packet)) {
452
+ consumed = true;
453
+ }
454
+ }
455
+
456
+ // Let scrolling, motion, release, and clicks outside tool results reach the
457
+ // normal TUI input chain (including other extensions such as pi-zentui).
458
+ return consumed ? { consume: true } : undefined;
459
+ }
460
+
461
+ function teardownToolMouseInteraction(): void {
462
+ toolMouseInputUnsubscribe?.();
463
+ toolMouseInputUnsubscribe = null;
464
+ try {
465
+ toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
466
+ } catch {
467
+ // The terminal may already be closed during shutdown.
468
+ }
469
+ try {
470
+ toolMouseUi?.setWidget?.(TOOL_MOUSE_WIDGET_KEY, undefined);
471
+ } catch {
472
+ // The UI context may already have been reset during /reload.
473
+ }
474
+ toolMouseTui = null;
475
+ toolMouseUi = null;
476
+ }
477
+
478
+ function installToolMouseInteraction(ctx: any): void {
479
+ teardownToolMouseInteraction();
480
+ if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
481
+ if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function") return;
482
+
483
+ toolMouseUi = ctx.ui;
484
+ ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any) => {
485
+ toolMouseTui = tui;
486
+ tui?.terminal?.write?.(TOOL_MOUSE_ENABLE);
487
+ // Keep the widget visually empty so Pi's default input bar/layout remains.
488
+ return { render: () => [], invalidate() {} };
489
+ });
490
+ toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
491
+ }
492
+
304
493
  // Bright green for success icon (truecolor ANSI escape)
305
494
  const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
306
495
  const ANSI_RESET = "\x1b[0m";
@@ -895,9 +1084,11 @@ export default function (pi: ExtensionAPI) {
895
1084
  pi.on("session_start", async (_event, ctx) => {
896
1085
  diffPreviewByCall.clear();
897
1086
  updateStatus(ctx);
1087
+ installToolMouseInteraction(ctx);
898
1088
  });
899
1089
 
900
1090
  pi.on("session_shutdown", async () => {
1091
+ teardownToolMouseInteraction();
901
1092
  deactivateGlobalToolRendering();
902
1093
  deactivateLegacyCompactionRendering();
903
1094
  clearAllAnimations();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -26,8 +26,7 @@
26
26
  "preversion": "npm run typecheck && npm test",
27
27
  "release": "npm version patch && npm publish",
28
28
  "release:minor": "npm version minor && npm publish",
29
- "release:major": "npm version major && npm publish",
30
- "postpublish": "git push && git push --tags"
29
+ "release:major": "npm version major && npm publish"
31
30
  },
32
31
  "peerDependencies": {
33
32
  "@earendil-works/pi-ai": "*",