pi-claude-code-ui 1.0.73 → 1.0.75

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/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  > [!IMPORTANT]
4
4
  > **1.0.69 — package rename (permanent).** Canonical npm name is now [`pi-claude-code-ui`](https://www.npmjs.com/package/pi-claude-code-ui). `pi-claude-style-tools` is legacy and will not receive further releases. Install with `pi install npm:pi-claude-code-ui` or `npm i pi-claude-code-ui`.
5
5
 
6
+ ## 1.0.75 — 2026-07-29
7
+
8
+ ### Fixed
9
+
10
+ - **Herdr progress with spinner verbs** — themed working messages (`Cooking…`, `Syncing…`, …) no longer break [herdr](https://herdr.dev) agent progress. Stock herdr pi screen detection only matches literal `Working...`; when `HERDR_ENV=1` the spinner now reports `working` / `idle` over herdr’s socket (same `herdr:pi` lifecycle authority as the official pi integration). Spinner UI and verbs are unchanged. Outside herdr the extension is a no-op for this path.
11
+
12
+ ## 1.0.74 — 2026-07-18
13
+
14
+ ### Fixed
15
+
16
+ - **Grouped tool boxes keep Agent breathe aligned** — group rows strip all breathe glyphs (including `·` and the blank off-phase) before re-prefixing a fresh light, so titles no longer walk sideways. Group header/child lights also follow the shared blink phase (and Agent breathe) instead of wall-clock `isBlinkOn()`.
17
+
6
18
  ## 1.0.73 — 2026-07-18
7
19
 
8
20
  ### Fixed
@@ -559,20 +559,33 @@ function themeStatusDot(theme: Theme, colorKey: "success" | "error" | "dim" | "m
559
559
  return theme.fg(colorKey, `${STATUS_DOT_BOLD}${STATUS_DOT_FILLED}`);
560
560
  }
561
561
 
562
- function agentBreatheDot(theme: Theme): string {
562
+ function agentBreatheGlyphRaw(): string {
563
563
  // Always exactly one display cell — matches ordinary tool dots, keeps titles aligned.
564
- const glyph = AGENT_BREATHE_GLYPHS[_globalBlinkPhaseIndex % AGENT_BREATHE_LEN];
564
+ return AGENT_BREATHE_GLYPHS[_globalBlinkPhaseIndex % AGENT_BREATHE_LEN];
565
+ }
566
+
567
+ function paintAgentBreatheDot(colorAnsi: string = TOOL_STATUS_SUCCESS): string {
568
+ const glyph = agentBreatheGlyphRaw();
565
569
  if (glyph === " ") return " ";
566
570
  // Bold only on the largest frame so weight changes without shifting the cell.
567
571
  const bold = glyph === "●" ? STATUS_DOT_BOLD : "";
572
+ return `${colorAnsi}${bold}${glyph}${TRANSPARENT_RESET}`;
573
+ }
574
+
575
+ function agentBreatheDot(theme: Theme): string {
576
+ const glyph = agentBreatheGlyphRaw();
577
+ if (glyph === " ") return " ";
578
+ const bold = glyph === "●" ? STATUS_DOT_BOLD : "";
568
579
  return theme.fg("success", `${bold}${glyph}`);
569
580
  }
570
581
 
571
- function groupStatusLight(status: ToolStatus): string {
582
+ function groupStatusLight(status: ToolStatus, options?: { agentBreathe?: boolean }): string {
572
583
  const color = status === "success" ? TOOL_STATUS_SUCCESS : status === "error" ? TOOL_STATUS_ERROR : TOOL_STATUS_PENDING;
573
584
  if (status === "pending") {
574
- // On phase: solid green. Off phase: disappear completely (space keeps alignment).
575
- return isBlinkOn() ? paintStatusDot(TOOL_STATUS_SUCCESS) : " ";
585
+ // Prefer the shared blink phase over wall-clock so group lights stay in sync
586
+ // with the global timer (and Agent breathe). Space keeps column alignment.
587
+ if (options?.agentBreathe) return paintAgentBreatheDot(TOOL_STATUS_SUCCESS);
588
+ return _globalBlinkPhase ? paintStatusDot(TOOL_STATUS_SUCCESS) : " ";
576
589
  }
577
590
  return paintStatusDot(color);
578
591
  }
@@ -610,7 +623,13 @@ function stripToolChrome(lines: string[]): string[] {
610
623
  }
611
624
 
612
625
  function stripLeadingToolStatus(line: string): string {
613
- return line.replace(/^((?:\x1b\[[0-9;]*m|[ \t]|[├└│─])*)(?:\x1b\[[0-9;]*m)*[●○✗■⬤•](?:\x1b\[[0-9;]*m)*\s+/, "$1");
626
+ // Drop the single-cell status marker so group rows can re-prefix a fresh light.
627
+ // Include Agent breathe glyphs (·) and the blank off-phase (space) so the title
628
+ // never keeps a leftover marker that shifts when size changes.
629
+ return line.replace(
630
+ /^((?:\x1b\[[0-9;]*m|[ \t]|[├└│─])*)(?:\x1b\[[0-9;]*m)*(?:[●○✗■⬤•·]| )(?:\x1b\[[0-9;]*m)*\s+/,
631
+ "$1",
632
+ );
614
633
  }
615
634
 
616
635
  function trimAnsiLeft(text: string): string {
@@ -684,19 +703,30 @@ function branchContinuation(index: number, total: number, theme?: Theme): string
684
703
  return index === total - 1 ? " " : ` ${rule}│${TRANSPARENT_RESET} `;
685
704
  }
686
705
 
687
- function formatBranchedToolLines(lines: string[], index: number, total: number, width: number, status: ToolStatus): string[] {
706
+ function formatBranchedToolLines(
707
+ lines: string[],
708
+ index: number,
709
+ total: number,
710
+ width: number,
711
+ status: ToolStatus,
712
+ options?: { agentBreathe?: boolean },
713
+ ): string[] {
688
714
  const output: string[] = [];
689
715
  const content = lines.filter((line) => isTerminalImageLine(line) || stripAnsi(line).trim().length > 0);
690
716
  const safeContent = content.length > 0 ? content : [""];
691
- const light = groupStatusLight(status);
717
+ const light = groupStatusLight(status, options);
692
718
  for (let lineIndex = 0; lineIndex < safeContent.length; lineIndex++) {
693
719
  const line = safeContent[lineIndex];
694
720
  if (isTerminalImageLine(line)) {
695
721
  output.push(line);
696
722
  continue;
697
723
  }
724
+ // Always strip any leftover status marker from the child call line before
725
+ // re-prefixing. Agent breathe used · which the old stripper missed, so the
726
+ // title walked sideways as size changed inside groups.
727
+ const body = lineIndex === 0 ? removeGroupedToolPrefix(line) : trimAnsiLeft(line);
698
728
  const prefix = lineIndex === 0 ? `${branchPrefix(index, total)}${light} ` : `${branchContinuation(index, total)} `;
699
- output.push(clampLineWidth(`${prefix}${trimAnsiLeft(line)}`, width));
729
+ output.push(clampLineWidth(`${prefix}${body}`, width));
700
730
  }
701
731
  return output;
702
732
  }
@@ -818,7 +848,11 @@ class ToolGroupComponent extends Container {
818
848
  const label = getToolGroupLabel(this.tools);
819
849
  const names = groupedName ? "" : formatToolNameList(this.tools);
820
850
  const overall: ToolStatus = status.error > 0 ? "error" : status.pending > 0 ? "pending" : "success";
821
- const light = groupStatusLight(overall);
851
+ // Group header breathes only when every pending member is Agent-family;
852
+ // mixed groups keep the ordinary on/off light.
853
+ const pendingTools = this.tools.filter((tool) => getToolStatusForGroup(tool) === "pending");
854
+ const headerBreathe = pendingTools.length > 0 && pendingTools.every((tool) => isAgentFamilyToolName(getToolName(tool)));
855
+ const light = groupStatusLight(overall, { agentBreathe: headerBreathe });
822
856
  const summaryLabel = `${label}:`;
823
857
  const countParts: string[] = [];
824
858
  if (status.pending) countParts.push(statusText("pending", status.pending));
@@ -835,7 +869,14 @@ class ToolGroupComponent extends Container {
835
869
  const rawLines = this.expanded
836
870
  ? getExpandedToolGroupLines(tool, childWidth, groupedName ? label : undefined)
837
871
  : [getCompactToolLine(tool, childWidth, groupedName ? label : undefined)];
838
- const branched = formatBranchedToolLines(rawLines, index, total, safeWidth, getToolStatusForGroup(tool));
872
+ const branched = formatBranchedToolLines(
873
+ rawLines,
874
+ index,
875
+ total,
876
+ safeWidth,
877
+ getToolStatusForGroup(tool),
878
+ { agentBreathe: isAgentFamilyToolName(getToolName(tool)) },
879
+ );
839
880
  for (let i = 0; i < branched.length; i++) {
840
881
  lines.push(clampLineWidth(branched[i], safeWidth));
841
882
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import net from "node:net";
2
3
 
3
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
5
  import { Loader } from "@earendil-works/pi-tui";
@@ -509,6 +510,162 @@ function statusText(text: string): string {
509
510
  return `${STATUS_DIM}${text}${RESET}`;
510
511
  }
511
512
 
513
+ // ---------------------------------------------------------------------------
514
+ // Herdr lifecycle reporting
515
+ //
516
+ // Stock herdr pi screen detection only matches the literal "Working...".
517
+ // This extension replaces that text with themed spinner verbs (Cooking…,
518
+ // Syncing…, …), so herdr sidebars go idle/stale while pi is actually busy.
519
+ // When running inside herdr (HERDR_ENV=1), report working/idle over the
520
+ // socket the same way herdr's official pi integration does. UI is unchanged.
521
+ // ---------------------------------------------------------------------------
522
+
523
+ type HerdrAgentState = "working" | "idle";
524
+
525
+ const HERDR_ENV = process.env.HERDR_ENV;
526
+ const HERDR_SOCKET_PATH = process.env.HERDR_SOCKET_PATH;
527
+ const HERDR_SOCKET_ENDPOINT =
528
+ process.platform === "win32" && HERDR_SOCKET_PATH
529
+ ? `\\\\.\\pipe\\${HERDR_SOCKET_PATH}`
530
+ : HERDR_SOCKET_PATH;
531
+ const HERDR_PANE_ID = process.env.HERDR_PANE_ID;
532
+ // Must be exactly "herdr:pi" — herdr only grants full lifecycle authority
533
+ // (skip screen-manifest fallback) to that official source pair with agent "pi".
534
+ const HERDR_SOURCE = "herdr:pi";
535
+
536
+ function herdrEnabled(): boolean {
537
+ return HERDR_ENV === "1" && !!HERDR_SOCKET_PATH && !!HERDR_PANE_ID;
538
+ }
539
+
540
+ function herdrSendAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
541
+ if (!herdrEnabled()) return Promise.resolve(true);
542
+
543
+ return new Promise((resolve) => {
544
+ let done = false;
545
+ let timeout: ReturnType<typeof setTimeout> | undefined;
546
+ const finish = (delivered: boolean) => {
547
+ if (done) return;
548
+ done = true;
549
+ if (timeout) clearTimeout(timeout);
550
+ socket.destroy();
551
+ resolve(delivered);
552
+ };
553
+
554
+ const socket = net.createConnection(HERDR_SOCKET_ENDPOINT!);
555
+ socket.on("error", () => finish(false));
556
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
557
+ socket.on("data", () => finish(true));
558
+ socket.on("end", () => finish(false));
559
+ timeout = setTimeout(() => finish(false), timeoutMs);
560
+ unrefTimer(timeout);
561
+ });
562
+ }
563
+
564
+ async function herdrSend(request: unknown): Promise<void> {
565
+ if (await herdrSendAttempt(request, 500)) return;
566
+ await herdrSendAttempt(request, 1500);
567
+ }
568
+
569
+ let herdrReportSeq = Date.now() * 1000;
570
+ let herdrSessionId: string | undefined;
571
+ let herdrSessionPath: string | undefined;
572
+ let herdrSendInFlight = false;
573
+ let herdrQueued: { state: HerdrAgentState; seq: number } | undefined;
574
+ let herdrLastState: HerdrAgentState | undefined;
575
+
576
+ function herdrNextSeq(): number {
577
+ herdrReportSeq += 1;
578
+ return herdrReportSeq;
579
+ }
580
+
581
+ function herdrUpdateSessionRef(ctx: any): void {
582
+ try {
583
+ const file = ctx?.sessionManager?.getSessionFile?.();
584
+ herdrSessionPath =
585
+ typeof file === "string" && file.startsWith("/") ? file : undefined;
586
+ } catch {
587
+ herdrSessionPath = undefined;
588
+ }
589
+
590
+ try {
591
+ const id = ctx?.sessionManager?.getSessionId?.();
592
+ herdrSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
593
+ } catch {
594
+ herdrSessionId = undefined;
595
+ }
596
+ }
597
+
598
+ function herdrWithSessionRef(params: Record<string, unknown>): Record<string, unknown> {
599
+ if (herdrSessionPath) return { ...params, agent_session_path: herdrSessionPath };
600
+ if (herdrSessionId) return { ...params, agent_session_id: herdrSessionId };
601
+ return params;
602
+ }
603
+
604
+ function herdrCurrentSessionRef(): Record<string, unknown> | undefined {
605
+ if (herdrSessionPath) return { agent_session_path: herdrSessionPath };
606
+ if (herdrSessionId) return { agent_session_id: herdrSessionId };
607
+ return undefined;
608
+ }
609
+
610
+ function herdrReportSession(sessionStartSource?: string): Promise<void> {
611
+ const sessionRef = herdrCurrentSessionRef();
612
+ if (!sessionRef) return Promise.resolve();
613
+
614
+ return herdrSend({
615
+ id: `${HERDR_SOURCE}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
616
+ method: "pane.report_agent_session",
617
+ params: {
618
+ pane_id: HERDR_PANE_ID,
619
+ source: HERDR_SOURCE,
620
+ agent: "pi",
621
+ seq: herdrNextSeq(),
622
+ session_start_source: sessionStartSource,
623
+ ...sessionRef,
624
+ },
625
+ });
626
+ }
627
+
628
+ function herdrSendState(state: HerdrAgentState, seq = herdrNextSeq()): Promise<void> {
629
+ return herdrSend({
630
+ id: `${HERDR_SOURCE}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
631
+ method: "pane.report_agent",
632
+ params: herdrWithSessionRef({
633
+ pane_id: HERDR_PANE_ID,
634
+ source: HERDR_SOURCE,
635
+ agent: "pi",
636
+ state,
637
+ seq,
638
+ }),
639
+ });
640
+ }
641
+
642
+ function herdrQueueState(state: HerdrAgentState): void {
643
+ herdrQueued = { state, seq: herdrNextSeq() };
644
+ if (!herdrSendInFlight) void herdrDrainQueue();
645
+ }
646
+
647
+ async function herdrDrainQueue(): Promise<void> {
648
+ if (herdrSendInFlight) return;
649
+ herdrSendInFlight = true;
650
+ try {
651
+ while (herdrQueued) {
652
+ const next = herdrQueued;
653
+ herdrQueued = undefined;
654
+ await herdrSendState(next.state, next.seq);
655
+ }
656
+ } finally {
657
+ herdrSendInFlight = false;
658
+ if (herdrQueued) void herdrDrainQueue();
659
+ }
660
+ }
661
+
662
+ function herdrPublishState(state: HerdrAgentState, force = false): void {
663
+ if (!herdrEnabled()) return;
664
+ if (!force && state === herdrLastState) return;
665
+ herdrLastState = state;
666
+ herdrQueueState(state);
667
+ }
668
+
512
669
  // ---------------------------------------------------------------------------
513
670
  // Extension
514
671
  // ---------------------------------------------------------------------------
@@ -545,6 +702,9 @@ export default function (pi: ExtensionAPI) {
545
702
  let turnActive = false;
546
703
  let lastWorkingMessage: string | null = null;
547
704
  let activeCtx: { ui: any; hasUI: boolean } | null = null;
705
+ // Root interactive session only — nested/non-UI contexts must not fight
706
+ // herdr state authority for this pane.
707
+ let herdrRootSession = false;
548
708
 
549
709
  function getEffortSuffix(): string {
550
710
  try {
@@ -709,6 +869,31 @@ export default function (pi: ExtensionAPI) {
709
869
  restoreDefaultWorkingMessage();
710
870
  }
711
871
 
872
+ function herdrMarkWorking(ctx?: any): void {
873
+ if (!herdrRootSession) return;
874
+ if (ctx) herdrUpdateSessionRef(ctx);
875
+ void herdrReportSession();
876
+ herdrPublishState("working");
877
+ }
878
+
879
+ function herdrMarkIdle(ctx?: any, force = false): void {
880
+ if (!herdrRootSession) return;
881
+ if (ctx) herdrUpdateSessionRef(ctx);
882
+ herdrPublishState("idle", force);
883
+ }
884
+
885
+ // session_start: claim root UI session + announce native session identity.
886
+ pi.on("session_start", async (event, ctx) => {
887
+ if (!herdrEnabled()) return;
888
+ if (ctx?.hasUI !== true) return;
889
+ herdrRootSession = true;
890
+ herdrUpdateSessionRef(ctx);
891
+ await herdrReportSession((event as any)?.reason);
892
+ // Reload mid-run: isIdle() false means work is still in flight.
893
+ const working = ctx?.isIdle?.() === false;
894
+ herdrPublishState(working ? "working" : "idle", true);
895
+ });
896
+
712
897
  function onThinkingEnd(): void {
713
898
  if (thinkingStatus !== "thinking") return;
714
899
  const duration = Date.now() - thinkingStartTime;
@@ -728,8 +913,9 @@ export default function (pi: ExtensionAPI) {
728
913
  if (!agentStartTime) agentStartTime = Date.now();
729
914
  });
730
915
 
731
- pi.on("agent_start", async () => {
916
+ pi.on("agent_start", async (_event, ctx) => {
732
917
  if (!agentStartTime) agentStartTime = Date.now();
918
+ herdrMarkWorking(ctx);
733
919
  });
734
920
 
735
921
  pi.on("turn_start", async (_event, ctx) => {
@@ -749,6 +935,9 @@ export default function (pi: ExtensionAPI) {
749
935
  scheduleThoughtStatusClear();
750
936
  }
751
937
  startRefreshLoop();
938
+ // turn_start is the earliest UI-visible busy signal; cover cases where
939
+ // agent_start already fired or is ordering-sensitive across reloads.
940
+ herdrMarkWorking(ctx);
752
941
  });
753
942
 
754
943
  pi.on("message_update", async (event, ctx) => {
@@ -843,9 +1032,23 @@ export default function (pi: ExtensionAPI) {
843
1032
  clearDisplay();
844
1033
  });
845
1034
 
1035
+ // Prefer agent_settled over agent_end: pi may still auto-retry, compact, or
1036
+ // drain a follow-up queue after agent_end. Settled means truly idle.
1037
+ // Cast: agent_settled landed after some older @earendil-works/pi-coding-agent
1038
+ // type packages still used for local typecheck; runtime pi has the event.
1039
+ (pi as ExtensionAPI & {
1040
+ on(event: "agent_settled", handler: (event: unknown, ctx: any) => void | Promise<void>): void;
1041
+ }).on("agent_settled", async (_event, ctx) => {
1042
+ if (!herdrRootSession) return;
1043
+ if (ctx?.isIdle?.() !== true) return;
1044
+ herdrMarkIdle(ctx);
1045
+ });
1046
+
846
1047
  pi.on("session_shutdown", async () => {
847
1048
  turnActive = false;
848
1049
  clearDisplay();
1050
+ herdrMarkIdle(undefined, true);
1051
+ herdrRootSession = false;
849
1052
  activeCtx = null;
850
1053
  });
851
1054
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.73",
3
+ "version": "1.0.75",
4
4
  "description": "Claude Code-style grouped tool rows for pi with Ctrl+O/Ctrl+Shift+O previews, math rendering, and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",