pi-claude-code-ui 1.0.92 → 1.0.95

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.
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import net from "node:net";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { basename, join } from "node:path";
5
5
 
6
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import { Loader } from "@earendil-works/pi-tui";
@@ -476,6 +476,61 @@ function pickVerb(): string {
476
476
  }
477
477
 
478
478
  /** Format elapsed ms as compact duration: 5s, 1m 23s, 1h 2m 3s */
479
+ // What a running tool is doing, for the working line ("Running npm test…",
480
+ // "Reading stats.py…"). Random verbs stay for the time the model is thinking.
481
+ function clip(text: string, max: number): string {
482
+ const oneLine = text.split("\n")[0]?.trim() ?? "";
483
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
484
+ }
485
+
486
+ function humanizeToolName(name: string): string {
487
+ const direct = /^mcp__.+?__(.+)$/.exec(name);
488
+ return (direct ? direct[1] : name)
489
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
490
+ .replace(/[_-]+/g, " ")
491
+ .replace(/\b\w/g, (char) => char.toUpperCase());
492
+ }
493
+
494
+ export function describeToolActivity(name: string, args: any): string {
495
+ const str = (...keys: string[]): string => {
496
+ for (const key of keys) {
497
+ const value = args?.[key];
498
+ if (typeof value === "string" && value.trim()) return value.trim();
499
+ }
500
+ return "";
501
+ };
502
+ const file = basename(str("path", "file_path"));
503
+ switch (name) {
504
+ case "bash": {
505
+ const command = clip(str("command"), 50);
506
+ return command ? `Running ${command}` : "Running a shell command";
507
+ }
508
+ case "read":
509
+ return file ? `Reading ${file}` : "Reading a file";
510
+ case "edit":
511
+ return file ? `Editing ${file}` : "Editing a file";
512
+ case "write":
513
+ return file ? `Writing ${file}` : "Writing a file";
514
+ case "grep":
515
+ case "find": {
516
+ const pattern = clip(str("pattern"), 40);
517
+ return pattern ? `Searching for ${pattern}` : "Searching";
518
+ }
519
+ case "ls":
520
+ return `Listing ${str("path") || "."}`;
521
+ case "Agent": {
522
+ const description = clip(str("description"), 50);
523
+ return description ? `Waiting for agent: ${description}` : "Waiting for an agent";
524
+ }
525
+ case "ask_user_question":
526
+ case "questionnaire":
527
+ case "question":
528
+ return "Waiting for your answer";
529
+ default:
530
+ return `Running ${humanizeToolName(name)}`;
531
+ }
532
+ }
533
+
479
534
  function formatDuration(ms: number): string {
480
535
  const totalSec = Math.floor(ms / 1000);
481
536
  const h = Math.floor(totalSec / 3600);
@@ -490,22 +545,26 @@ function formatCount(value: number): string {
490
545
  return new Intl.NumberFormat("en-US").format(value);
491
546
  }
492
547
 
493
- function estimateResponseLength(message: any): number {
494
- if (!Array.isArray(message?.content)) return 0;
495
- return message.content.reduce((sum: number, block: any) =>
496
- sum + (block?.type === "text" && typeof block.text === "string" ? block.text.length : 0), 0);
497
- }
498
-
499
- function textBlockLengths(message: any): number[] {
500
- if (!Array.isArray(message?.content)) return [];
501
- const lengths: number[] = [];
502
- for (let i = 0; i < message.content.length; i++) {
503
- const block = message.content[i];
504
- if (block?.type === "text" && typeof block.text === "string") {
505
- lengths[i] = block.text.length;
548
+ /** Characters the model produced in one content block: text, thinking, or tool-call arguments. */
549
+ function contentBlockLength(block: any): number {
550
+ if (block?.type === "text" && typeof block.text === "string") return block.text.length;
551
+ if (block?.type === "thinking" && typeof block.thinking === "string") return block.thinking.length;
552
+ if (block?.type === "toolCall") {
553
+ try {
554
+ return JSON.stringify(block.arguments ?? {}).length;
555
+ } catch {
556
+ return 0;
506
557
  }
507
558
  }
508
- return lengths;
559
+ return 0;
560
+ }
561
+
562
+ /** Output tokens of a finished assistant message: the provider's count, or an estimate (chars / 4). */
563
+ function messageOutputTokens(message: any): number {
564
+ const reported = message?.usage?.output;
565
+ if (typeof reported === "number" && Number.isFinite(reported) && reported > 0) return reported;
566
+ if (!Array.isArray(message?.content)) return 0;
567
+ return Math.round(message.content.reduce((sum: number, block: any) => sum + contentBlockLength(block), 0) / 4);
509
568
  }
510
569
 
511
570
  function statusText(text: string): string {
@@ -721,8 +780,12 @@ export default function (pi: ExtensionAPI) {
721
780
  let completionTimer: ReturnType<typeof setTimeout> | null = null;
722
781
  let thoughtStatusTimer: ReturnType<typeof setTimeout> | null = null;
723
782
  let currentVerb = "";
724
- let responseLength = 0;
725
- let responseTextBlockLengths: number[] = [];
783
+ // Output tokens for the whole run, like pi-polish: finished messages add their real
784
+ // usage.output; the message still streaming adds an estimate (chars / 4) of its text,
785
+ // thinking and tool-call arguments, so the count climbs as the model writes.
786
+ let runOutputTokens = 0;
787
+ let streamedChars = 0;
788
+ let streamedBlockLengths: number[] = [];
726
789
  let thinkingStatus: "thinking" | number /* duration ms */ | null = null;
727
790
  let thinkingStartTime = 0;
728
791
  let thoughtForSetAt = 0;
@@ -730,6 +793,8 @@ export default function (pi: ExtensionAPI) {
730
793
  let turnActive = false;
731
794
  let lastWorkingMessage: string | null = null;
732
795
  let activeCtx: { ui: any; hasUI: boolean } | null = null;
796
+ // Tools running right now, in start order; the latest one names the working line.
797
+ const runningTools = new Map<string, { name: string; args: unknown }>();
733
798
  // Root interactive session only — nested/non-UI contexts must not fight
734
799
  // herdr state authority for this pane.
735
800
  let herdrRootSession = false;
@@ -746,7 +811,7 @@ export default function (pi: ExtensionAPI) {
746
811
 
747
812
  function buildWorkingMessage(): string {
748
813
  const elapsed = Date.now() - (agentStartTime || turnStartTime);
749
- const tokenCount = Math.max(0, Math.round(responseLength / 4));
814
+ const tokenCount = outputTokenCount();
750
815
  const statusParts: string[] = [];
751
816
 
752
817
  if (thinkingStatus === "thinking") {
@@ -763,22 +828,39 @@ export default function (pi: ExtensionAPI) {
763
828
  statusParts.push(formatDuration(elapsed));
764
829
  }
765
830
 
766
- let message = `${CLAUDE_ORANGE}${currentVerb}…${RESET}`;
831
+ const running = [...runningTools.values()];
832
+ const latest = running[running.length - 1];
833
+ const verb = latest
834
+ ? `${describeToolActivity(latest.name, latest.args)}${running.length > 1 ? ` (+${running.length - 1} more)` : ""}`
835
+ : currentVerb;
836
+ let message = `${CLAUDE_ORANGE}${verb}…${RESET}`;
767
837
  if (statusParts.length > 0) {
768
838
  message += statusText(` (${statusParts.join(" · ")})`);
769
839
  }
770
840
  return message;
771
841
  }
772
842
 
773
- function setResponseTextBlockLength(index: number, length: number): void {
774
- const previous = responseTextBlockLengths[index] ?? 0;
775
- responseTextBlockLengths[index] = Math.max(0, length);
776
- responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
843
+ function setStreamedBlockLength(index: number, length: number): void {
844
+ const previous = streamedBlockLengths[index] ?? 0;
845
+ streamedBlockLengths[index] = Math.max(0, length);
846
+ streamedChars = Math.max(0, streamedChars + streamedBlockLengths[index] - previous);
847
+ }
848
+
849
+ function resetStreamed(): void {
850
+ streamedBlockLengths = [];
851
+ streamedChars = 0;
852
+ }
853
+
854
+ function outputTokenCount(): number {
855
+ return runOutputTokens + Math.round(streamedChars / 4);
777
856
  }
778
857
 
779
- function resetResponseTracking(message?: any): void {
780
- responseTextBlockLengths = message ? textBlockLengths(message) : [];
781
- responseLength = message ? estimateResponseLength(message) : 0;
858
+ /** Start of a top-level request (not steering or follow-ups inside it). */
859
+ function beginRun(now = Date.now()): void {
860
+ if (agentStartTime) return;
861
+ agentStartTime = now;
862
+ runOutputTokens = 0;
863
+ resetStreamed();
782
864
  }
783
865
 
784
866
  function syncWorkingMessage(force = false): void {
@@ -815,7 +897,7 @@ export default function (pi: ExtensionAPI) {
815
897
 
816
898
  function getWorkingMessageIntervalMs(): number {
817
899
  const elapsed = Date.now() - (agentStartTime || turnStartTime);
818
- const tokenCount = Math.max(0, Math.round(responseLength / 4));
900
+ const tokenCount = outputTokenCount();
819
901
  // Keep ticking once per second even when idle so /cc-spinner changes
820
902
  // take effect within ~1s and elapsed-time crossover into the timer-on
821
903
  // state still fires close to 30s. syncWorkingMessage short-circuits
@@ -902,7 +984,8 @@ export default function (pi: ExtensionAPI) {
902
984
  turnStartTime = 0;
903
985
  thinkingStatus = null;
904
986
  thoughtForSetAt = 0;
905
- resetResponseTracking();
987
+ runOutputTokens = 0;
988
+ resetStreamed();
906
989
  restoreDefaultWorkingMessage();
907
990
  }
908
991
 
@@ -946,12 +1029,12 @@ export default function (pi: ExtensionAPI) {
946
1029
 
947
1030
  pi.on("before_agent_start", async () => {
948
1031
  // Start once per top-level request. Steering/follow-up messages while the
949
- // agent is active must not reset the timer.
950
- if (!agentStartTime) agentStartTime = Date.now();
1032
+ // agent is active must not reset the timer or the token count.
1033
+ beginRun();
951
1034
  });
952
1035
 
953
1036
  pi.on("agent_start", async (_event, ctx) => {
954
- if (!agentStartTime) agentStartTime = Date.now();
1037
+ beginRun();
955
1038
  herdrMarkWorking(ctx);
956
1039
  });
957
1040
 
@@ -961,9 +1044,9 @@ export default function (pi: ExtensionAPI) {
961
1044
  activeCtx = ctx;
962
1045
  applyThemeColors(ctx.ui?.theme);
963
1046
  turnStartTime = Date.now();
964
- if (!agentStartTime) agentStartTime = turnStartTime;
1047
+ beginRun(turnStartTime);
965
1048
  currentVerb = pickVerb();
966
- resetResponseTracking();
1049
+ resetStreamed();
967
1050
  clearCompletionTimer();
968
1051
  if (typeof thinkingStatus !== "number" || Date.now() - thoughtForSetAt >= THOUGHT_DISPLAY_MS) {
969
1052
  thinkingStatus = null;
@@ -982,21 +1065,19 @@ export default function (pi: ExtensionAPI) {
982
1065
  applyThemeColors(ctx.ui?.theme);
983
1066
  const evt = event.assistantMessageEvent;
984
1067
  let statusChanged = false;
985
- const previousTokenCount = Math.max(0, Math.round(responseLength / 4));
1068
+ const previousTokenCount = outputTokenCount();
986
1069
 
987
1070
  if (evt.type === "start") {
988
- resetResponseTracking();
989
- } else if (evt.type === "text_start") {
990
- setResponseTextBlockLength(evt.contentIndex, 0);
991
- } else if (evt.type === "text_delta") {
992
- const previous = responseTextBlockLengths[evt.contentIndex] ?? 0;
993
- setResponseTextBlockLength(evt.contentIndex, previous + (typeof evt.delta === "string" ? evt.delta.length : 0));
994
- } else if (evt.type === "text_end") {
995
- setResponseTextBlockLength(evt.contentIndex, typeof evt.content === "string" ? evt.content.length : 0);
996
- } else if (evt.type === "done") {
997
- resetResponseTracking(evt.message);
998
- } else if (evt.type === "error") {
999
- resetResponseTracking(evt.error);
1071
+ resetStreamed();
1072
+ } else if (evt.type === "text_start" || evt.type === "thinking_start" || evt.type === "toolcall_start") {
1073
+ setStreamedBlockLength(evt.contentIndex, 0);
1074
+ } else if (evt.type === "text_delta" || evt.type === "thinking_delta" || evt.type === "toolcall_delta") {
1075
+ const previous = streamedBlockLengths[evt.contentIndex] ?? 0;
1076
+ setStreamedBlockLength(evt.contentIndex, previous + (typeof evt.delta === "string" ? evt.delta.length : 0));
1077
+ } else if (evt.type === "text_end" || evt.type === "thinking_end") {
1078
+ setStreamedBlockLength(evt.contentIndex, typeof evt.content === "string" ? evt.content.length : 0);
1079
+ } else if (evt.type === "toolcall_end") {
1080
+ setStreamedBlockLength(evt.contentIndex, contentBlockLength({ type: "toolCall", arguments: evt.toolCall?.arguments }));
1000
1081
  }
1001
1082
 
1002
1083
  if (evt.type === "thinking_start") {
@@ -1019,12 +1100,33 @@ export default function (pi: ExtensionAPI) {
1019
1100
  return;
1020
1101
  }
1021
1102
 
1022
- const nextTokenCount = Math.max(0, Math.round(responseLength / 4));
1103
+ const nextTokenCount = outputTokenCount();
1023
1104
  if (previousTokenCount === 0 && nextTokenCount > 0) {
1024
1105
  rescheduleRefreshLoop();
1025
1106
  }
1026
1107
  });
1027
1108
 
1109
+ // A finished message swaps its estimate for the provider's real output-token count.
1110
+ pi.on("message_end", async (event: any) => {
1111
+ if (event?.message?.role !== "assistant") return;
1112
+ runOutputTokens += messageOutputTokens(event.message);
1113
+ resetStreamed();
1114
+ if (turnActive) syncWorkingMessage();
1115
+ });
1116
+
1117
+ pi.on("tool_execution_start", async (event: any, ctx) => {
1118
+ if (typeof event?.toolCallId !== "string") return;
1119
+ activeCtx = ctx;
1120
+ runningTools.set(event.toolCallId, { name: String(event.toolName ?? ""), args: event.args });
1121
+ if (turnActive) syncWorkingMessage(true);
1122
+ });
1123
+
1124
+ pi.on("tool_execution_end", async (event: any, ctx) => {
1125
+ if (typeof event?.toolCallId !== "string" || !runningTools.delete(event.toolCallId)) return;
1126
+ activeCtx = ctx;
1127
+ if (turnActive) syncWorkingMessage(true);
1128
+ });
1129
+
1028
1130
  pi.on("turn_end", async (_event, ctx) => {
1029
1131
  turnActive = false;
1030
1132
  activeCtx = ctx;
@@ -1060,13 +1162,13 @@ export default function (pi: ExtensionAPI) {
1060
1162
  return;
1061
1163
  }
1062
1164
 
1063
- responseLength = 0;
1064
- responseTextBlockLengths = [];
1165
+ resetStreamed();
1065
1166
  });
1066
1167
 
1067
1168
  pi.on("agent_end", async () => {
1068
1169
  turnActive = false;
1069
1170
  agentStartTime = 0;
1171
+ runningTools.clear();
1070
1172
  // Preserve the just-finished "Turn took …" line. Pi emits agent_end
1071
1173
  // immediately after the final turn, so clearing here made the completion
1072
1174
  // status disappear before users could see it.
@@ -1088,6 +1190,7 @@ export default function (pi: ExtensionAPI) {
1088
1190
 
1089
1191
  pi.on("session_shutdown", async () => {
1090
1192
  turnActive = false;
1193
+ runningTools.clear();
1091
1194
  clearDisplay();
1092
1195
  herdrMarkIdle(undefined, true);
1093
1196
  herdrRootSession = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.92",
3
+ "version": "1.0.95",
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",
@@ -32,6 +32,7 @@
32
32
  "extensions/spinner.ts",
33
33
  "extensions/settings-path.ts",
34
34
  "extensions/bash-command.ts",
35
+ "extensions/bash-file-edit.ts",
35
36
  "extensions/theme-colors.ts",
36
37
  "extensions/terminal-theme.ts",
37
38
  "config/config.example.json",