pi-bar 0.3.23 → 0.3.25

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/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  claude-opus-4.7 ❯ think:med ❯ 2.6% / 1.0M ❯ Inspecting package structure ❯ plan:active
10
10
  ```
11
11
 
12
- ![pi-bar with low context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.23/assets/screenshot-green.png)
13
- ![pi-bar with medium context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.23/assets/screenshot-yellow.png)
14
- ![pi-bar with high context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.23/assets/screenshot-red.png)
12
+ ![pi-bar with low context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.25/assets/screenshot-green.png)
13
+ ![pi-bar with medium context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.25/assets/screenshot-yellow.png)
14
+ ![pi-bar with high context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.25/assets/screenshot-red.png)
15
15
 
16
16
  ## Why use it?
17
17
 
@@ -43,7 +43,11 @@ type SerializedStatusFilter =
43
43
  type TldrActivityType =
44
44
  | "user_message"
45
45
  | "assistant_update"
46
+ | "tool_input_start"
47
+ | "tool_input_update"
48
+ | "tool_input_end"
46
49
  | "tool_call"
50
+ | "tool_execution_update"
47
51
  | "tool_result"
48
52
  | "assistant_final"
49
53
  | "assistant_failure";
@@ -54,17 +58,36 @@ type TldrActivity = {
54
58
  displayPriority: TldrDisplayPriority;
55
59
  text: string;
56
60
  toolCallId?: string;
61
+ // Identifies a continuous streaming run (tool input arg streaming, or tool
62
+ // execution output streaming). Activities sharing a group represent updates
63
+ // to the same task; staleness handling treats them as one stream.
64
+ progressGroup?: string;
57
65
  };
58
66
  type TldrCheckpoint = {
59
67
  activityIndex: number;
68
+ activityType: TldrActivityType;
60
69
  displayPriority: TldrDisplayPriority;
61
70
  text: string;
71
+ progressGroup?: string;
62
72
  };
63
73
  type TldrCheckpointJob = {
64
74
  activityIndex: number;
75
+ activityType: TldrActivityType;
65
76
  displayPriority: TldrDisplayPriority;
66
77
  runId: number;
78
+ progressGroup?: string;
67
79
  };
80
+
81
+ // Activities that represent ongoing streaming work. These are treated
82
+ // specially: their checkpoints can render even when newer same-stream activity
83
+ // exists, and the display refresh slows to a 10s cadence for repeat renders so
84
+ // the footer does not become a model-call ticker.
85
+ function isToolProgressActivity(activityType: TldrActivityType): boolean {
86
+ return (
87
+ activityType === "tool_input_update" ||
88
+ activityType === "tool_execution_update"
89
+ );
90
+ }
68
91
  type TldrModelPreference = { provider: string; id: string };
69
92
  type FastModelAuth = {
70
93
  model: Parameters<typeof complete>[0];
@@ -88,6 +111,20 @@ const TLDR_MAX_TOKENS = 120;
88
111
  const TLDR_REQUEST_TIMEOUT_MS = 2_000;
89
112
  const TLDR_DISPLAY_UPDATE_INTERVAL_MS = 1_200;
90
113
  const TLDR_TARGET_SUMMARY_CHARS = 60;
114
+ // While rendering successive progress checkpoints from the same stream (e.g.
115
+ // long bash output), slow the display refresh dramatically. The TLDR text
116
+ // barely changes between adjacent chunks; refreshing every 1.2s would burn API
117
+ // calls and footer renders for noise.
118
+ const TOOL_PROGRESS_DISPLAY_UPDATE_INTERVAL_MS = 10_000;
119
+ const MAX_TOOL_PROGRESS_PARTIAL_CHARS = 280;
120
+ // Burst debouncing on the generation side: coalesce rapid-fire activities into
121
+ // one model call. Quiet window catches short bursts; max wait surfaces progress
122
+ // during continuous activity.
123
+ const NORMAL_CHECKPOINT_QUIET_MS = 700;
124
+ const NORMAL_CHECKPOINT_MAX_WAIT_MS = 2_500;
125
+ // Defensive ceiling: even if the model ignores the length instruction, never
126
+ // blast a large payload into the footer.
127
+ const MAX_SAFE_TLDR_CHARS = 240;
91
128
  const CONFIG_PATH =
92
129
  process.env.PI_BAR_CONFIG ?? join(homedir(), ".pi", "agent", "pi-bar.json");
93
130
 
@@ -236,11 +273,13 @@ function resolveTldrModelPreference(cwd: string): TldrModelPreference | undefine
236
273
  return globalModel ? parseTldrModelSpec(globalModel) : undefined;
237
274
  }
238
275
 
276
+ // Fallback order optimizes for low latency + low cost first. The TLDR path does
277
+ // not need reasoning quality; codex/gpt-5.4-mini is fastest in practice.
239
278
  const FAST_TLDR_MODELS: readonly TldrModelPreference[] = [
240
- { provider: "anthropic", id: "claude-haiku-4-5" },
241
- { provider: "anthropic", id: "claude-haiku-4-5-20251001" },
242
279
  { provider: "openai-codex", id: "gpt-5.4-mini" },
243
280
  { provider: "openai-codex", id: "gpt-5.3-codex-spark" },
281
+ { provider: "anthropic", id: "claude-haiku-4-5" },
282
+ { provider: "anthropic", id: "claude-haiku-4-5-20251001" },
244
283
  ];
245
284
 
246
285
  function formatTldrModelKey(model: TldrModelPreference): string {
@@ -281,8 +320,19 @@ async function getFastTldrModelAuth(
281
320
  }
282
321
 
283
322
  function truncateText(text: string, maxChars: number): string {
284
- if (text.length <= maxChars) return text;
285
- return `${text.slice(0, maxChars - 1)}…`;
323
+ if (maxChars <= 0) return "";
324
+ const chars = Array.from(text);
325
+ if (chars.length <= maxChars) return text;
326
+ if (maxChars === 1) return "…";
327
+
328
+ // Head+tail preserves both "what started" and "how it ended". Most useful for
329
+ // long bash/tool outputs where the final lines carry the outcome.
330
+ const retainedChars = maxChars - 1;
331
+ const headLength = Math.ceil(retainedChars / 2);
332
+ const tailLength = Math.floor(retainedChars / 2);
333
+ const head = chars.slice(0, headLength).join("");
334
+ const tail = tailLength > 0 ? chars.slice(-tailLength).join("") : "";
335
+ return `${head}…${tail}`;
286
336
  }
287
337
 
288
338
  function tailText(text: string, maxChars: number): string {
@@ -427,13 +477,58 @@ function extractTextContent(content: readonly unknown[] | undefined): string | u
427
477
  return compacted.length > 0 ? compacted : undefined;
428
478
  }
429
479
 
480
+ type ToolInputAssistantEvent =
481
+ | { type: "toolcall_start"; contentIndex: number; partial: unknown }
482
+ | {
483
+ type: "toolcall_delta";
484
+ contentIndex: number;
485
+ delta: string;
486
+ partial: unknown;
487
+ }
488
+ | {
489
+ type: "toolcall_end";
490
+ contentIndex: number;
491
+ toolCall: { id: string; name: string; arguments?: unknown };
492
+ partial: unknown;
493
+ };
494
+
495
+ function toolCallAtContentIndex(
496
+ partial: unknown,
497
+ contentIndex: number,
498
+ ): { id: string; name: string; arguments?: unknown } | undefined {
499
+ if (!partial || typeof partial !== "object") return undefined;
500
+ const content = (partial as { content?: unknown }).content;
501
+ if (!Array.isArray(content)) return undefined;
502
+ const block = content[contentIndex];
503
+ if (!block || typeof block !== "object") return undefined;
504
+ const record = block as Record<string, unknown>;
505
+ if (record.type !== "toolCall") return undefined;
506
+ if (typeof record.id !== "string" || typeof record.name !== "string") {
507
+ return undefined;
508
+ }
509
+ return {
510
+ id: record.id,
511
+ name: record.name,
512
+ arguments: record.arguments,
513
+ };
514
+ }
515
+
430
516
  class TldrFactCollector {
431
517
  private nextIndex = 1;
432
518
  private readonly activities: TldrActivity[] = [];
519
+ // Streaming tool inputs (`toolcall_start/delta/end` from message_update)
520
+ // arrive before the final tool id is available; content index is the best
521
+ // stable anchor until the id appears.
522
+ private readonly activeToolInputAnchors = new Map<string, string>();
433
523
 
434
524
  resetConversation(): void {
435
525
  this.nextIndex = 1;
436
526
  this.activities.splice(0);
527
+ this.activeToolInputAnchors.clear();
528
+ }
529
+
530
+ latestActivity(): TldrActivity | undefined {
531
+ return this.activities[this.activities.length - 1];
437
532
  }
438
533
 
439
534
  recordUserMessage(prompt: string): TldrActivity {
@@ -465,6 +560,79 @@ class TldrFactCollector {
465
560
  );
466
561
  }
467
562
 
563
+ // Stage 1: tool input streaming. The model is writing tool name + args; pi
564
+ // surfaces this via `assistantMessageEvent` on `message_update` with
565
+ // toolcall_start / toolcall_delta / toolcall_end. Useful when args are long
566
+ // (big edits, structured plans) and the user wants visible progress before
567
+ // the actual tool execution starts.
568
+ recordToolInputActivity(
569
+ event: ToolInputAssistantEvent | undefined,
570
+ ): TldrActivity | undefined {
571
+ if (!event) return undefined;
572
+
573
+ switch (event.type) {
574
+ case "toolcall_start": {
575
+ const toolCall = toolCallAtContentIndex(event.partial, event.contentIndex);
576
+ const toolName = toolCall?.name ?? "unknown";
577
+ const anchorKey = toolCall?.id ?? `content:${event.contentIndex}`;
578
+ const anchorText = `tool input: ${toolName}`;
579
+ this.activeToolInputAnchors.set(anchorKey, anchorText);
580
+ return this.addActivity(
581
+ "tool_input_start",
582
+ "normal",
583
+ anchorText,
584
+ toolCall?.id,
585
+ );
586
+ }
587
+ case "toolcall_delta": {
588
+ const toolCall = toolCallAtContentIndex(event.partial, event.contentIndex);
589
+ const anchorKey = toolCall?.id ?? `content:${event.contentIndex}`;
590
+ const anchorText =
591
+ this.activeToolInputAnchors.get(anchorKey) ??
592
+ `tool input: ${toolCall?.name ?? "unknown"}`;
593
+
594
+ // Repeated delta updates for the same call collapse into the latest
595
+ // snapshot. Otherwise long arg streams would push out useful facts.
596
+ const last = this.activities[this.activities.length - 1];
597
+ if (
598
+ last?.activityType === "tool_input_update" &&
599
+ last.progressGroup === `tool-input:${anchorKey}`
600
+ ) {
601
+ this.activities.pop();
602
+ }
603
+
604
+ const argSnapshot = compactValue(toolCall?.arguments, 200);
605
+ const chunk = event.delta
606
+ ? `latest: ${truncateText(compactText(event.delta), 120)}`
607
+ : undefined;
608
+ const update = [argSnapshot, chunk].filter(Boolean).join("; ");
609
+
610
+ return this.addActivity(
611
+ "tool_input_update",
612
+ "normal",
613
+ `${anchorText}; ${update || "streaming"}`,
614
+ toolCall?.id,
615
+ `tool-input:${anchorKey}`,
616
+ );
617
+ }
618
+ case "toolcall_end": {
619
+ const anchorKey = event.toolCall.id;
620
+ const anchorText =
621
+ this.activeToolInputAnchors.get(anchorKey) ??
622
+ `tool input: ${event.toolCall.name}`;
623
+ this.activeToolInputAnchors.delete(anchorKey);
624
+
625
+ const args = compactValue(event.toolCall.arguments, 240);
626
+ return this.addActivity(
627
+ "tool_input_end",
628
+ "normal",
629
+ args ? `${anchorText} completed; ${args}` : `${anchorText} completed`,
630
+ event.toolCall.id,
631
+ );
632
+ }
633
+ }
634
+ }
635
+
468
636
  recordToolCall(event: {
469
637
  toolName: string;
470
638
  input?: Record<string, unknown>;
@@ -478,6 +646,45 @@ class TldrFactCollector {
478
646
  );
479
647
  }
480
648
 
649
+ // Stage 2: tool execution streaming. The host is running the tool and
650
+ // emitting partial output. Tagged with progressGroup so adjacent chunks of
651
+ // the same execution are treated as one continuous stream.
652
+ recordToolExecutionUpdate(event: {
653
+ toolCallId: string;
654
+ toolName: string;
655
+ partialResult: unknown;
656
+ }): TldrActivity {
657
+ const partialText =
658
+ extractTextContent(
659
+ Array.isArray((event.partialResult as { content?: unknown })?.content)
660
+ ? ((event.partialResult as { content: unknown[] }).content as unknown[])
661
+ : undefined,
662
+ ) ??
663
+ compactValue(event.partialResult, MAX_TOOL_PROGRESS_PARTIAL_CHARS) ??
664
+ "streaming";
665
+
666
+ const progressGroup = `tool-execution:${event.toolCallId}`;
667
+
668
+ // Adjacent execution-update activities for the same stream collapse into
669
+ // the latest. The retained activity list stays bounded and the model sees
670
+ // one current snapshot of in-progress output.
671
+ const last = this.activities[this.activities.length - 1];
672
+ if (
673
+ last?.activityType === "tool_execution_update" &&
674
+ last.progressGroup === progressGroup
675
+ ) {
676
+ this.activities.pop();
677
+ }
678
+
679
+ return this.addActivity(
680
+ "tool_execution_update",
681
+ "normal",
682
+ `running ${event.toolName}; ${tailText(partialText, MAX_TOOL_PROGRESS_PARTIAL_CHARS)}`,
683
+ event.toolCallId,
684
+ progressGroup,
685
+ );
686
+ }
687
+
481
688
  recordToolResult(event: {
482
689
  toolName: string;
483
690
  input?: Record<string, unknown>;
@@ -496,15 +703,24 @@ class TldrFactCollector {
496
703
  const status = isError ? "error" : "ok";
497
704
  const tool = compactToolInput(event.toolName, event.input);
498
705
 
499
- // Result fact subsumes the prior tool_call fact for the same call. Drop the
500
- // matching tool_call so the model sees one combined fact instead of two.
706
+ // The completed result fact subsumes all in-progress facts for the same
707
+ // call: the original tool_call, any tool_input_* lifecycle facts, and any
708
+ // tool_execution_update progress. The model only needs the final outcome.
501
709
  if (event.toolCallId) {
502
- const idx = this.activities.findIndex(
503
- (activity) =>
504
- activity.activityType === "tool_call" &&
505
- activity.toolCallId === event.toolCallId,
506
- );
507
- if (idx !== -1) this.activities.splice(idx, 1);
710
+ const id = event.toolCallId;
711
+ for (let i = this.activities.length - 1; i >= 0; i--) {
712
+ const activity = this.activities[i];
713
+ if (activity.toolCallId !== id) continue;
714
+ if (
715
+ activity.activityType === "tool_call" ||
716
+ activity.activityType === "tool_input_start" ||
717
+ activity.activityType === "tool_input_update" ||
718
+ activity.activityType === "tool_input_end" ||
719
+ activity.activityType === "tool_execution_update"
720
+ ) {
721
+ this.activities.splice(i, 1);
722
+ }
723
+ }
508
724
  }
509
725
 
510
726
  return this.addActivity(
@@ -572,13 +788,15 @@ class TldrFactCollector {
572
788
  displayPriority: TldrDisplayPriority,
573
789
  text: string,
574
790
  toolCallId?: string,
791
+ progressGroup?: string,
575
792
  ): TldrActivity {
576
- const activity = {
793
+ const activity: TldrActivity = {
577
794
  index: this.nextIndex,
578
795
  activityType,
579
796
  displayPriority,
580
797
  text: truncateText(text, MAX_ACTIVITY_TEXT_CHARS),
581
798
  toolCallId,
799
+ progressGroup,
582
800
  };
583
801
  this.nextIndex++;
584
802
  this.activities.push(activity);
@@ -604,6 +822,15 @@ class FooterTldrEngine {
604
822
  private displayTimer?: ReturnType<typeof setTimeout>;
605
823
  private abortController?: AbortController;
606
824
  private currentText: string | null = null;
825
+ // Normal-priority bursts wait for a quiet window before triggering a model
826
+ // call. Pi often emits many rapid activities (e.g. streaming reads); without
827
+ // debouncing we would hammer the TLDR model for intermediate states.
828
+ private pendingNormalCheckpoint?: TldrCheckpointJob;
829
+ private normalCheckpointTimer?: ReturnType<typeof setTimeout>;
830
+ private normalCheckpointBurstStartedAt?: number;
831
+ // Last progress stream that produced a rendered checkpoint. Used to slow the
832
+ // display refresh for repeat renders from the same stream.
833
+ private lastRenderedProgressGroup?: string;
607
834
 
608
835
  constructor(private readonly requestRender: () => void) {}
609
836
 
@@ -628,7 +855,22 @@ class FooterTldrEngine {
628
855
  this.enqueue(ctx, this.facts.recordUserMessage(prompt));
629
856
  }
630
857
 
631
- recordAssistantUpdate(ctx: ExtensionContext, message: unknown): void {
858
+ recordAssistantUpdate(
859
+ ctx: ExtensionContext,
860
+ message: unknown,
861
+ assistantMessageEvent?: unknown,
862
+ ): void {
863
+ // Tool input streaming hides inside `message_update`. Drain that signal
864
+ // first; only if no tool-input fact was produced does this look like a
865
+ // plain assistant text update worth recording.
866
+ const toolInputActivity = this.facts.recordToolInputActivity(
867
+ normalizeToolInputEvent(assistantMessageEvent),
868
+ );
869
+ if (toolInputActivity) {
870
+ this.enqueue(ctx, toolInputActivity);
871
+ return;
872
+ }
873
+
632
874
  const activity = this.facts.recordAssistantUpdate(message);
633
875
  if (activity) this.enqueue(ctx, activity);
634
876
  }
@@ -640,6 +882,17 @@ class FooterTldrEngine {
640
882
  this.enqueue(ctx, this.facts.recordToolCall(event));
641
883
  }
642
884
 
885
+ recordToolExecutionUpdate(
886
+ ctx: ExtensionContext,
887
+ event: {
888
+ toolCallId: string;
889
+ toolName: string;
890
+ partialResult: unknown;
891
+ },
892
+ ): void {
893
+ this.enqueue(ctx, this.facts.recordToolExecutionUpdate(event));
894
+ }
895
+
643
896
  recordToolResult(
644
897
  ctx: ExtensionContext,
645
898
  event: {
@@ -674,6 +927,7 @@ class FooterTldrEngine {
674
927
  this.lastDisplayAt = Number.NEGATIVE_INFINITY;
675
928
  this.acceptedCheckpoints.splice(0);
676
929
  this.pendingDisplayCheckpoint = undefined;
930
+ this.lastRenderedProgressGroup = undefined;
677
931
  this.currentText = null;
678
932
  this.requestRender();
679
933
  }
@@ -681,28 +935,89 @@ class FooterTldrEngine {
681
935
  private cancelWork(): void {
682
936
  if (this.displayTimer) clearTimeout(this.displayTimer);
683
937
  this.displayTimer = undefined;
938
+ this.clearPendingNormalCheckpoint();
684
939
  this.checkpointQueue.splice(0);
685
940
  this.inFlightCheckpoint = undefined;
686
941
  this.abortController?.abort();
687
942
  this.abortController = undefined;
688
943
  }
689
944
 
945
+ private clearNormalCheckpointTimer(): void {
946
+ if (!this.normalCheckpointTimer) return;
947
+ clearTimeout(this.normalCheckpointTimer);
948
+ this.normalCheckpointTimer = undefined;
949
+ }
950
+
951
+ private clearPendingNormalCheckpoint(): void {
952
+ this.clearNormalCheckpointTimer();
953
+ this.pendingNormalCheckpoint = undefined;
954
+ this.normalCheckpointBurstStartedAt = undefined;
955
+ }
956
+
957
+ private scheduleNormalCheckpoint(
958
+ ctx: ExtensionContext,
959
+ job: TldrCheckpointJob,
960
+ ): void {
961
+ this.pendingNormalCheckpoint = job;
962
+ this.normalCheckpointBurstStartedAt ??= performance.now();
963
+ this.clearNormalCheckpointTimer();
964
+
965
+ const elapsedSinceBurstStarted =
966
+ performance.now() - this.normalCheckpointBurstStartedAt;
967
+ const maxWaitRemainingMs =
968
+ NORMAL_CHECKPOINT_MAX_WAIT_MS - elapsedSinceBurstStarted;
969
+ const delayMs = Math.max(
970
+ 0,
971
+ Math.min(NORMAL_CHECKPOINT_QUIET_MS, maxWaitRemainingMs),
972
+ );
973
+
974
+ if (delayMs === 0) {
975
+ this.flushPendingNormalCheckpoint(ctx);
976
+ return;
977
+ }
978
+
979
+ this.normalCheckpointTimer = setTimeout(() => {
980
+ this.normalCheckpointTimer = undefined;
981
+ this.flushPendingNormalCheckpoint(ctx);
982
+ }, delayMs);
983
+ }
984
+
985
+ private flushPendingNormalCheckpoint(ctx: ExtensionContext): void {
986
+ const job = this.pendingNormalCheckpoint;
987
+ this.clearPendingNormalCheckpoint();
988
+ if (!job || job.runId !== this.runId) return;
989
+
990
+ this.checkpointQueue = this.checkpointQueue.filter(
991
+ (queued) => queued.displayPriority !== "normal",
992
+ );
993
+ this.checkpointQueue.push(job);
994
+ this.pump(ctx);
995
+ }
996
+
690
997
  private enqueue(ctx: ExtensionContext, activity: TldrActivity): void {
691
998
  if (!ctx.hasUI) return;
692
- const job = {
999
+ const job: TldrCheckpointJob = {
693
1000
  activityIndex: activity.index,
1001
+ activityType: activity.activityType,
694
1002
  displayPriority: activity.displayPriority,
695
1003
  runId: this.runId,
1004
+ progressGroup: activity.progressGroup,
696
1005
  };
697
1006
 
698
1007
  if (job.displayPriority === "immediate") {
699
1008
  this.clearPendingDisplay();
1009
+ this.clearPendingNormalCheckpoint();
700
1010
  this.lastRenderedText = "";
701
1011
  this.checkpointQueue.splice(0);
702
1012
  this.abortInFlightCheckpoint();
703
1013
  this.checkpointQueue.push(job);
704
- } else if (job.displayPriority === "final") {
1014
+ this.pump(ctx);
1015
+ return;
1016
+ }
1017
+
1018
+ if (job.displayPriority === "final") {
705
1019
  this.clearPendingDisplay();
1020
+ this.clearPendingNormalCheckpoint();
706
1021
  this.checkpointQueue = this.checkpointQueue.filter(
707
1022
  (queued) => queued.displayPriority !== "normal",
708
1023
  );
@@ -710,19 +1025,23 @@ class FooterTldrEngine {
710
1025
  this.abortInFlightCheckpoint();
711
1026
  }
712
1027
  this.checkpointQueue.push(job);
713
- } else {
714
- this.checkpointQueue = this.checkpointQueue.filter(
715
- (queued) => queued.displayPriority !== "normal",
716
- );
717
- this.checkpointQueue.push(job);
1028
+ this.pump(ctx);
1029
+ return;
718
1030
  }
719
1031
 
720
- this.pump(ctx);
1032
+ // Normal-priority: debounce into a single coalesced job. The latest job
1033
+ // always wins because raw activity since the last accepted checkpoint is
1034
+ // what the model already sees, regardless of which job triggers the call.
1035
+ this.scheduleNormalCheckpoint(ctx, job);
721
1036
  }
722
1037
 
723
- private clearPendingDisplay(): void {
1038
+ private clearDisplayTimer(): void {
724
1039
  if (this.displayTimer) clearTimeout(this.displayTimer);
725
1040
  this.displayTimer = undefined;
1041
+ }
1042
+
1043
+ private clearPendingDisplay(): void {
1044
+ this.clearDisplayTimer();
726
1045
  this.pendingDisplayCheckpoint = undefined;
727
1046
  }
728
1047
 
@@ -780,10 +1099,12 @@ class FooterTldrEngine {
780
1099
  const rawText = extractTextContent(response.content) ?? "";
781
1100
  const text = sanitizeTldrText(rawText);
782
1101
  if (!text) return;
783
- const checkpoint = {
1102
+ const checkpoint: TldrCheckpoint = {
784
1103
  activityIndex: job.activityIndex,
1104
+ activityType: job.activityType,
785
1105
  displayPriority: job.displayPriority,
786
1106
  text,
1107
+ progressGroup: job.progressGroup,
787
1108
  };
788
1109
  this.acceptCheckpoint(checkpoint);
789
1110
  this.considerDisplayingCheckpoint(ctx, checkpoint);
@@ -815,7 +1136,7 @@ class FooterTldrEngine {
815
1136
  checkpoint: TldrCheckpoint,
816
1137
  ): void {
817
1138
  if (checkpoint.activityIndex <= this.lastRenderedActivityIndex) return;
818
- if (checkpoint.activityIndex !== this.facts.latestActivityIndex()) return;
1139
+ if (this.shouldDropStaleCheckpoint(checkpoint)) return;
819
1140
 
820
1141
  if (checkpoint.displayPriority !== "normal" || !this.lastRenderedText) {
821
1142
  this.clearPendingDisplay();
@@ -823,22 +1144,66 @@ class FooterTldrEngine {
823
1144
  return;
824
1145
  }
825
1146
 
1147
+ const displayIntervalMs = this.displayIntervalFor(checkpoint);
826
1148
  const elapsedMs = performance.now() - this.lastDisplayAt;
827
- if (elapsedMs >= TLDR_DISPLAY_UPDATE_INTERVAL_MS) {
1149
+ if (elapsedMs >= displayIntervalMs) {
828
1150
  this.clearPendingDisplay();
829
1151
  this.renderCheckpoint(checkpoint);
830
1152
  return;
831
1153
  }
832
1154
 
833
1155
  this.pendingDisplayCheckpoint = checkpoint;
834
- if (this.displayTimer) return;
1156
+ // Recompute the timer for each newly accepted pending checkpoint. A long
1157
+ // 10s progress-stream debounce should not block a subsequent ordinary
1158
+ // update that wants to render at the normal 1.2s cadence.
1159
+ this.clearDisplayTimer();
835
1160
  this.displayTimer = setTimeout(() => {
836
1161
  this.displayTimer = undefined;
837
1162
  const pending = this.pendingDisplayCheckpoint;
838
1163
  this.pendingDisplayCheckpoint = undefined;
839
- if (!pending || pending.activityIndex !== this.facts.latestActivityIndex()) return;
1164
+ if (!pending) return;
1165
+ if (this.shouldDropStaleCheckpoint(pending)) return;
840
1166
  this.renderCheckpoint(pending);
841
- }, TLDR_DISPLAY_UPDATE_INTERVAL_MS - elapsedMs);
1167
+ }, displayIntervalMs - elapsedMs);
1168
+ }
1169
+
1170
+ // A model call for chunk N may finish after chunk N+1 has been recorded.
1171
+ // Most stale checkpoints are dropped, but progress streams are an exception:
1172
+ // while the same stream is still active, a slightly-behind TLDR is still
1173
+ // useful, and dropping it would leave the footer stuck on older text.
1174
+ private shouldDropStaleCheckpoint(checkpoint: TldrCheckpoint): boolean {
1175
+ if (checkpoint.activityIndex === this.facts.latestActivityIndex()) {
1176
+ return false;
1177
+ }
1178
+
1179
+ // Boundary checkpoints (user_message, assistant_final, etc.) must stay
1180
+ // latest-only. A stale prompt-start or final TLDR would describe the wrong
1181
+ // turn entirely.
1182
+ if (checkpoint.displayPriority !== "normal") return true;
1183
+
1184
+ if (!isToolProgressActivity(checkpoint.activityType)) return true;
1185
+ if (!checkpoint.progressGroup) return true;
1186
+
1187
+ const latestActivity = this.facts.latestActivity();
1188
+ return !(
1189
+ latestActivity &&
1190
+ latestActivity.index > checkpoint.activityIndex &&
1191
+ latestActivity.activityType === checkpoint.activityType &&
1192
+ latestActivity.progressGroup === checkpoint.progressGroup
1193
+ );
1194
+ }
1195
+
1196
+ private displayIntervalFor(checkpoint: TldrCheckpoint): number {
1197
+ if (!isToolProgressActivity(checkpoint.activityType)) {
1198
+ return TLDR_DISPLAY_UPDATE_INTERVAL_MS;
1199
+ }
1200
+ // First render from a new progress group uses the normal cadence so the
1201
+ // user sees the stream's first TLDR quickly. Subsequent renders for the
1202
+ // same group slow down: the model output barely changes between chunks.
1203
+ return checkpoint.progressGroup &&
1204
+ checkpoint.progressGroup === this.lastRenderedProgressGroup
1205
+ ? TOOL_PROGRESS_DISPLAY_UPDATE_INTERVAL_MS
1206
+ : TLDR_DISPLAY_UPDATE_INTERVAL_MS;
842
1207
  }
843
1208
 
844
1209
  private renderCheckpoint(checkpoint: TldrCheckpoint): void {
@@ -847,6 +1212,9 @@ class FooterTldrEngine {
847
1212
  this.lastRenderedActivityIndex = checkpoint.activityIndex;
848
1213
  this.lastRenderedText = checkpoint.text;
849
1214
  this.lastDisplayAt = performance.now();
1215
+ this.lastRenderedProgressGroup = isToolProgressActivity(checkpoint.activityType)
1216
+ ? checkpoint.progressGroup
1217
+ : undefined;
850
1218
  this.currentText = checkpoint.text;
851
1219
  this.requestRender();
852
1220
  }
@@ -883,7 +1251,13 @@ function checkpointSystemPrompt(job: TldrCheckpointJob): string {
883
1251
  job.displayPriority === "final"
884
1252
  ? "Start with a past-tense verb."
885
1253
  : "Start with a present-tense -ing verb.";
886
- return `Write one plain-English TLDR for a terminal coding agent.
1254
+ // Frame the model as a human developer describing visible work, not an agent
1255
+ // summarizing its own mechanics. Avoids leaks like tool names, prompt labels,
1256
+ // or our internal scaffolding (activity indexes, prior checkpoints).
1257
+ return `Write one plain-English TLDR for a Pi coding agent.
1258
+ Describe the work progress as if a human developer were doing it.
1259
+ Focus on the task activity and current outcome, not agent mechanics.
1260
+ Do not mention tools, tool calls, prompts, messages, model output, or implementation details.
887
1261
  Use the prior TLDRs for context and the new activity for the update.
888
1262
  Summarize the current state of work; do not narrate the history.
889
1263
  If context is sparse, still summarize the available activity.
@@ -892,7 +1266,6 @@ Return one concise status fragment under ${TLDR_TARGET_SUMMARY_CHARS} characters
892
1266
  Omit subjects like "the agent" or "it".
893
1267
  Prefer verb + direct object. Include outcome only if important.
894
1268
  Do not address the user.
895
- Do not mention activity numbers, indexes, checkpoints, prior TLDRs, or phrases like "through activity".
896
1269
  Output only the status fragment itself. No prefixes, labels, bullets, or quotes.
897
1270
  Plain text only; no markdown, JSON, code, file paths, or tool names.
898
1271
  ${tenseInstruction}`;
@@ -910,20 +1283,178 @@ function formatRawActivity(activity: TldrActivity): string {
910
1283
  return `- ${activity.text}`;
911
1284
  }
912
1285
 
1286
+ // Narrows the unknown `assistantMessageEvent` from `message_update` to the
1287
+ // three tool-input streaming variants. Other event shapes (text_start, etc.)
1288
+ // produce undefined, signalling "no tool input fact to record".
1289
+ function normalizeToolInputEvent(value: unknown): ToolInputAssistantEvent | undefined {
1290
+ if (!value || typeof value !== "object") return undefined;
1291
+ const record = value as Record<string, unknown>;
1292
+ const type = record.type;
1293
+ if (type !== "toolcall_start" && type !== "toolcall_delta" && type !== "toolcall_end") {
1294
+ return undefined;
1295
+ }
1296
+ if (typeof record.contentIndex !== "number") return undefined;
1297
+
1298
+ if (type === "toolcall_delta") {
1299
+ return {
1300
+ type,
1301
+ contentIndex: record.contentIndex,
1302
+ delta: typeof record.delta === "string" ? record.delta : "",
1303
+ partial: record.partial,
1304
+ };
1305
+ }
1306
+ if (type === "toolcall_end") {
1307
+ const toolCall = record.toolCall;
1308
+ if (
1309
+ !toolCall ||
1310
+ typeof toolCall !== "object" ||
1311
+ typeof (toolCall as Record<string, unknown>).id !== "string" ||
1312
+ typeof (toolCall as Record<string, unknown>).name !== "string"
1313
+ ) {
1314
+ return undefined;
1315
+ }
1316
+ return {
1317
+ type,
1318
+ contentIndex: record.contentIndex,
1319
+ toolCall: toolCall as { id: string; name: string; arguments?: unknown },
1320
+ partial: record.partial,
1321
+ };
1322
+ }
1323
+ return {
1324
+ type,
1325
+ contentIndex: record.contentIndex,
1326
+ partial: record.partial,
1327
+ };
1328
+ }
1329
+
1330
+ // Strips ANSI/CSI/OSC/DCS/SOS/PM/APC escape sequences from model output before
1331
+ // it lands in the terminal. The TLDR text is model-controlled and renders into
1332
+ // the footer; the system prompt asking for plain text is not a security
1333
+ // boundary, so we defensively remove terminal controls here.
1334
+ const ESC_BYTE = 0x1b;
1335
+ const BEL_BYTE = 0x07;
1336
+ const ST_BYTE = 0x9c;
1337
+
1338
+ function isControlCharacter(code: number): boolean {
1339
+ return (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f);
1340
+ }
1341
+
1342
+ function isWhitespaceControl(code: number): boolean {
1343
+ return (
1344
+ code === 0x09 ||
1345
+ code === 0x0a ||
1346
+ code === 0x0b ||
1347
+ code === 0x0c ||
1348
+ code === 0x0d
1349
+ );
1350
+ }
1351
+
1352
+ function skipCsiSequence(text: string, startIndex: number): number {
1353
+ for (let index = startIndex; index < text.length; index++) {
1354
+ const code = text.charCodeAt(index);
1355
+ if (code >= 0x40 && code <= 0x7e) return index + 1;
1356
+ }
1357
+ return text.length;
1358
+ }
1359
+
1360
+ function skipStringControl(text: string, startIndex: number): number {
1361
+ for (let index = startIndex; index < text.length; index++) {
1362
+ const code = text.charCodeAt(index);
1363
+ if (code === BEL_BYTE || code === ST_BYTE) return index + 1;
1364
+ if (code === ESC_BYTE && text.charCodeAt(index + 1) === 0x5c) {
1365
+ return index + 2;
1366
+ }
1367
+ }
1368
+ return text.length;
1369
+ }
1370
+
1371
+ function skipEscapeSequence(text: string, escapeIndex: number): number {
1372
+ const nextCode = text.charCodeAt(escapeIndex + 1);
1373
+ if (Number.isNaN(nextCode)) return escapeIndex + 1;
1374
+
1375
+ switch (nextCode) {
1376
+ case 0x5b: // CSI: ESC [
1377
+ return skipCsiSequence(text, escapeIndex + 2);
1378
+ case 0x5d: // OSC: ESC ]
1379
+ case 0x50: // DCS: ESC P
1380
+ case 0x58: // SOS: ESC X
1381
+ case 0x5e: // PM: ESC ^
1382
+ case 0x5f: // APC: ESC _
1383
+ return skipStringControl(text, escapeIndex + 2);
1384
+ default:
1385
+ if (
1386
+ nextCode === 0x20 ||
1387
+ nextCode === 0x23 ||
1388
+ nextCode === 0x25 ||
1389
+ (nextCode >= 0x28 && nextCode <= 0x2f)
1390
+ ) {
1391
+ return Math.min(text.length, escapeIndex + 3);
1392
+ }
1393
+ return Math.min(text.length, escapeIndex + 2);
1394
+ }
1395
+ }
1396
+
1397
+ function stripTerminalControls(text: string): string {
1398
+ let stripped = "";
1399
+ for (let index = 0; index < text.length; ) {
1400
+ const code = text.charCodeAt(index);
1401
+
1402
+ if (code === ESC_BYTE) {
1403
+ index = skipEscapeSequence(text, index);
1404
+ continue;
1405
+ }
1406
+ if (code === 0x9b) {
1407
+ index = skipCsiSequence(text, index + 1);
1408
+ continue;
1409
+ }
1410
+ if (
1411
+ code === 0x90 ||
1412
+ code === 0x98 ||
1413
+ code === 0x9d ||
1414
+ code === 0x9e ||
1415
+ code === 0x9f
1416
+ ) {
1417
+ index = skipStringControl(text, index + 1);
1418
+ continue;
1419
+ }
1420
+ if (isControlCharacter(code)) {
1421
+ if (isWhitespaceControl(code)) stripped += " ";
1422
+ index++;
1423
+ continue;
1424
+ }
1425
+
1426
+ stripped += text[index];
1427
+ index++;
1428
+ }
1429
+ return stripped.replace(/\s+/gu, " ").trim();
1430
+ }
1431
+
1432
+ // Second layer: strip leaked prompt scaffolding such as "Through activity 12:".
1433
+ // The system prompt forbids these prefixes, but model outputs sometimes still
1434
+ // parrot them; the regex is narrow enough not to clip natural starts like
1435
+ // "Activity slowed after retry" or "Checkpointing release state".
913
1436
  const LEAKED_PREFIX_PATTERN =
914
1437
  /^\s*(?:[-*•]\s*)?(?:(?:through\s+activity|activity|checkpoint)\s+\d+\s*[:.\-—–]\s*|(?:tldr|summary)\s*[:.\-—–]\s*)+/i;
915
-
916
1438
  const LEADING_PUNCT_PATTERN = /^[\s\-—–•*:#.,;]+/;
917
1439
 
918
- function sanitizeTldrText(text: string): string {
919
- let cleaned = text.trim();
1440
+ function stripLeakedScaffolding(text: string): string {
1441
+ let cleaned = text;
920
1442
  let previous: string;
921
1443
  do {
922
1444
  previous = cleaned;
923
1445
  cleaned = cleaned.replace(LEAKED_PREFIX_PATTERN, "").trim();
924
1446
  cleaned = cleaned.replace(LEADING_PUNCT_PATTERN, "").trim();
925
1447
  } while (cleaned !== previous && cleaned.length > 0);
926
- return cleaned || text.trim();
1448
+ return cleaned;
1449
+ }
1450
+
1451
+ function sanitizeTldrText(
1452
+ text: string,
1453
+ maxChars = MAX_SAFE_TLDR_CHARS,
1454
+ ): string {
1455
+ const stripped = stripTerminalControls(text);
1456
+ const cleaned = stripLeakedScaffolding(stripped) || stripped;
1457
+ return truncateText(cleaned, maxChars);
927
1458
  }
928
1459
 
929
1460
  function shouldShowStatus(key: string, filter: StatusFilter): boolean {
@@ -1213,11 +1744,18 @@ export default function (pi: ExtensionAPI) {
1213
1744
  tldr.recordUserMessage(ctx, event.prompt);
1214
1745
  });
1215
1746
  pi.on("message_update", async (event, ctx) => {
1216
- tldr.recordAssistantUpdate(ctx, event.message);
1747
+ tldr.recordAssistantUpdate(
1748
+ ctx,
1749
+ event.message,
1750
+ (event as { assistantMessageEvent?: unknown }).assistantMessageEvent,
1751
+ );
1217
1752
  });
1218
1753
  pi.on("tool_call", async (event, ctx) => {
1219
1754
  tldr.recordToolCall(ctx, event);
1220
1755
  });
1756
+ pi.on("tool_execution_update", async (event, ctx) => {
1757
+ tldr.recordToolExecutionUpdate(ctx, event);
1758
+ });
1221
1759
  pi.on("tool_result", async (event, ctx) => {
1222
1760
  tldr.recordToolResult(ctx, event);
1223
1761
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bar",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "description": "Pi footer/statusline showing active model, thinking level, context pressure, live TLDR, and extension statuses.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -51,6 +51,6 @@
51
51
  "extensions": [
52
52
  "./extensions/status-footer.ts"
53
53
  ],
54
- "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.23/assets/screenshot-yellow.png"
54
+ "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.25/assets/screenshot-yellow.png"
55
55
  }
56
56
  }