pi-bar 0.3.6 → 0.3.7

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,17 +9,17 @@
9
9
  ◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M ❯ ⓘ Inspecting package structure ❯ ✧ plan:active
10
10
  ```
11
11
 
12
- ![pi-bar screenshot, three rows showing green, yellow, and red context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.6/assets/screenshot.png)
12
+ ![pi-bar screenshot, three rows showing green, yellow, and red context usage](https://cdn.jsdelivr.net/npm/pi-bar@0.3.7/assets/screenshot.png)
13
13
 
14
14
  ## Why use it?
15
15
 
16
16
  - **See the active model at a glance** — catch accidental model switches before an expensive or sensitive task starts.
17
17
  - **Track thinking level in place** — immediately notice when you are using the wrong reasoning setting.
18
18
  - **Watch context pressure early** — context usage turns green, yellow, then red as you approach the limit.
19
- - **Show pi-tldr inline** — when `pi-tldr` is installed, its current TLDR appears as a compact footer segment.
19
+ - **Show live TLDR inline** — pi-bar can generate a compact live summary directly in the footer.
20
20
  - **Keep extension statuses visible** — statuses set with `ctx.ui.setStatus()` still appear in the custom footer.
21
21
 
22
- pi-bar is intentionally tiny: one small extension, no broad behavior changes, and no commands to learn. It replaces pi's built-in footer with a compact model / thinking / context / tldr / extension-status line that refreshes when model, thinking level, context usage, pi-tldr, or extension statuses change.
22
+ pi-bar is intentionally tiny: one small extension, no broad behavior changes, and no commands to learn. It replaces pi's built-in footer with a compact model / thinking / context / tldr / extension-status line that refreshes when model, thinking level, context usage, generated TLDR, or extension statuses change.
23
23
 
24
24
  ## Install
25
25
 
@@ -44,23 +44,25 @@ PI_BAR_SHOW=model,thinking,context,tldr,extensions pi
44
44
  PI_BAR_SHOW=model,context pi
45
45
  ```
46
46
 
47
- Allowed segments are `model`, `thinking`, `context`, `tldr`, and `extensions`. The `tldr` segment is hidden unless `pi-tldr` has a current TLDR. The `extensions` segment is hidden when no extension has set a status.
47
+ Allowed segments are `model`, `thinking`, `context`, `tldr`, and `extensions`. The `tldr` segment is hidden until pi-bar generates a current TLDR. The `extensions` segment is hidden when no extension has set a status.
48
48
 
49
- ### Show pi-tldr in the footer
49
+ ### Configure live TLDR
50
50
 
51
- Install [pi-tldr](https://www.npmjs.com/package/pi-tldr) alongside pi-bar:
51
+ pi-bar generates live footer TLDRs from recent prompt, assistant, tool, and result activity. It uses a fast authenticated model and stays quiet when none is available. Limit segment width with:
52
52
 
53
53
  ```bash
54
- pi install npm:pi-tldr
54
+ PI_BAR_TLDR_MAX_WIDTH=48 pi
55
55
  ```
56
56
 
57
- pi-bar captures pi-tldr's `pi-tldr` widget and renders its current text as the `tldr` segment. Limit segment width with:
57
+ Default is `64`. Set `PI_BAR_SHOW` without `tldr` to hide it.
58
+
59
+ By default, pi-bar uses the first authenticated fast model from its fallback list. Override with:
58
60
 
59
61
  ```bash
60
- PI_BAR_TLDR_MAX_WIDTH=48 pi
62
+ PI_BAR_TLDR_MODEL=anthropic/claude-haiku-4-5 pi
61
63
  ```
62
64
 
63
- Default is `64`. Set `PI_BAR_SHOW` without `tldr` to hide it.
65
+ You can also set `bar.tldrModel` in pi settings; `tldr.model` is accepted for compatibility with existing settings.
64
66
 
65
67
  ### Configure extension statuses
66
68
 
@@ -108,10 +110,11 @@ PI_BAR_ICONS=plain pi
108
110
 
109
111
  ## Pairs well with
110
112
 
111
- - **[pi-tldr](https://www.npmjs.com/package/pi-tldr)** — show live summaries above the input bar; pi-bar mirrors them into the footer.
112
113
  - **[pi-chrome](https://www.npmjs.com/package/pi-chrome)** — give your Pi agent your real, signed-in Chrome. Use pi-bar's red-context threshold as the signal to wrap up long browser scrapes before context overflows.
113
114
  - **[pi-qq](https://www.npmjs.com/package/pi-qq)** — ask side questions about what the agent just did without polluting the transcript.
114
115
 
115
116
  ## Security note
116
117
 
117
118
  Pi extensions run with your local user permissions. Review any pi package source before installing it.
119
+
120
+ Live TLDR generation sends short snippets from recent prompt, assistant, tool, and result activity to the selected TLDR model provider. Disable the `tldr` segment with `PI_BAR_SHOW` if that is unacceptable.
@@ -20,19 +20,18 @@
20
20
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
22
  import { dirname, join } from "node:path";
23
+ import { complete, type UserMessage } from "@earendil-works/pi-ai";
23
24
  import {
24
25
  getSettingsListTheme,
26
+ SettingsManager,
25
27
  type ExtensionAPI,
26
28
  type ExtensionContext,
27
- type Theme,
28
29
  type ThemeColor,
29
30
  } from "@earendil-works/pi-coding-agent";
30
31
  import {
31
32
  Container,
32
- type Component,
33
33
  type SettingItem,
34
34
  SettingsList,
35
- type TUI,
36
35
  truncateToWidth,
37
36
  } from "@earendil-works/pi-tui";
38
37
 
@@ -44,17 +43,48 @@ type StatusFilter =
44
43
  type SerializedStatusFilter =
45
44
  | { mode: "all"; hidden: string[] }
46
45
  | { mode: "only"; shown: string[] };
47
- type TldrWidgetFactory = (tui: TUI, theme: Theme) => Component & {
48
- dispose?(): void;
46
+ type TldrActivityType =
47
+ | "user_message"
48
+ | "assistant_update"
49
+ | "tool_call"
50
+ | "tool_result"
51
+ | "assistant_final"
52
+ | "assistant_failure";
53
+ type TldrDisplayPriority = "immediate" | "normal" | "final";
54
+ type TldrActivity = {
55
+ index: number;
56
+ activityType: TldrActivityType;
57
+ displayPriority: TldrDisplayPriority;
58
+ text: string;
59
+ };
60
+ type TldrCheckpoint = {
61
+ activityIndex: number;
62
+ displayPriority: TldrDisplayPriority;
63
+ text: string;
64
+ };
65
+ type TldrCheckpointJob = {
66
+ activityIndex: number;
67
+ displayPriority: TldrDisplayPriority;
68
+ runId: number;
69
+ };
70
+ type TldrModelPreference = { provider: string; id: string };
71
+ type FastModelAuth = {
72
+ model: Parameters<typeof complete>[0];
73
+ apiKey: string;
74
+ headers?: Record<string, string>;
49
75
  };
50
- type CapturedTldrWidget =
51
- | { kind: "lines"; lines: string[] }
52
- | { kind: "factory"; factory: TldrWidgetFactory };
53
76
 
54
77
  const STATUS_FILTER_ENTRY_TYPE = "pi-bar-status-filter";
55
- const TLDR_WIDGET_KEY = "pi-tldr";
56
78
  const DEFAULT_TLDR_MAX_WIDTH = 64;
57
- const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
79
+ const SETTINGS_TLDR_KEY = "tldr";
80
+ const SETTINGS_BAR_KEY = "bar";
81
+ const MAX_ACTIVITY_TEXT_CHARS = 1_500;
82
+ const MAX_RETAINED_RAW_ACTIVITIES = 128;
83
+ const MAX_CONTEXT_CHECKPOINTS = 8;
84
+ const TLDR_MAX_TOKENS = 120;
85
+ const TLDR_REQUEST_TIMEOUT_MS = 2_000;
86
+ const TLDR_DISPLAY_UPDATE_INTERVAL_MS = 1_200;
87
+ const TLDR_TARGET_SUMMARY_CHARS = 80;
58
88
  const CONFIG_PATH =
59
89
  process.env.PI_BAR_CONFIG ?? join(homedir(), ".pi", "agent", "pi-bar.json");
60
90
 
@@ -221,52 +251,108 @@ function parseTldrMaxWidth(): number {
221
251
  return DEFAULT_TLDR_MAX_WIDTH;
222
252
  }
223
253
 
224
- function stripAnsi(value: string): string {
225
- return value.replace(ANSI_PATTERN, "");
254
+ function parseTldrModelSpec(value: string): TldrModelPreference | undefined {
255
+ const trimmed = value.trim();
256
+ if (!trimmed || trimmed === "auto") return undefined;
257
+ const separator = trimmed.indexOf("/");
258
+ if (separator <= 0 || separator === trimmed.length - 1) return undefined;
259
+ return { provider: trimmed.slice(0, separator), id: trimmed.slice(separator + 1) };
226
260
  }
227
261
 
228
- function extractTldrText(lines: readonly string[]): string | null {
229
- const parts = lines
230
- .map(stripAnsi)
231
- .map((line) => line.trimEnd())
232
- .filter((line) => line.trim().length > 0)
233
- .filter((line) => !/^[╭╰].*[╮╯]$/.test(line.trim()))
234
- .map((line) => {
235
- const trimmed = line.trim();
236
- if (trimmed.startsWith("│") && trimmed.endsWith("│")) {
237
- return trimmed.slice(1, -1).trim();
238
- }
239
- return trimmed.replace(/^tldr:\s*/i, "").trim();
240
- })
241
- .filter(Boolean);
262
+ function settingsModelValue(settings: Record<string, unknown>): string | undefined {
263
+ const bar = settings[SETTINGS_BAR_KEY];
264
+ if (bar && typeof bar === "object" && !Array.isArray(bar)) {
265
+ const value = (bar as Record<string, unknown>).tldrModel;
266
+ if (typeof value === "string") return value;
267
+ }
268
+
269
+ const tldr = settings[SETTINGS_TLDR_KEY];
270
+ if (tldr && typeof tldr === "object" && !Array.isArray(tldr)) {
271
+ const value = (tldr as Record<string, unknown>).model;
272
+ if (typeof value === "string") return value;
273
+ }
242
274
 
243
- const text = parts.join(" ").replace(/\s+/g, " ").trim();
244
- return text.length > 0 ? text : null;
275
+ return undefined;
245
276
  }
246
277
 
247
- function renderCapturedTldr(
248
- widget: CapturedTldrWidget | undefined,
249
- tui: TUI,
250
- theme: Theme,
251
- width: number,
252
- maxWidth: number,
253
- ): string | null {
254
- if (!widget) return null;
278
+ function resolveTldrModelPreference(cwd: string): TldrModelPreference | undefined {
279
+ const envModel = process.env.PI_BAR_TLDR_MODEL;
280
+ if (envModel) return parseTldrModelSpec(envModel);
255
281
 
256
- let lines: string[];
257
- if (widget.kind === "lines") {
258
- lines = widget.lines;
259
- } else {
260
- const component = widget.factory(tui, theme);
261
- try {
262
- lines = component.render(Math.max(12, width));
263
- } finally {
264
- component.dispose?.();
282
+ const settings = SettingsManager.create(cwd);
283
+ const projectModel = settingsModelValue(
284
+ settings.getProjectSettings() as Record<string, unknown>,
285
+ );
286
+ if (projectModel !== undefined) return parseTldrModelSpec(projectModel);
287
+
288
+ const globalModel = settingsModelValue(
289
+ settings.getGlobalSettings() as Record<string, unknown>,
290
+ );
291
+ return globalModel ? parseTldrModelSpec(globalModel) : undefined;
292
+ }
293
+
294
+ const FAST_TLDR_MODELS: readonly TldrModelPreference[] = [
295
+ { provider: "anthropic", id: "claude-haiku-4-5" },
296
+ { provider: "anthropic", id: "claude-haiku-4-5-20251001" },
297
+ { provider: "openai-codex", id: "gpt-5.4-mini" },
298
+ { provider: "openai-codex", id: "gpt-5.3-codex-spark" },
299
+ ];
300
+
301
+ function formatTldrModelKey(model: TldrModelPreference): string {
302
+ return `${model.provider}/${model.id}`;
303
+ }
304
+
305
+ async function getModelAuth(
306
+ ctx: ExtensionContext,
307
+ preference: TldrModelPreference,
308
+ ): Promise<FastModelAuth | undefined> {
309
+ const model = ctx.modelRegistry.find(preference.provider, preference.id);
310
+ if (!model) return undefined;
311
+
312
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
313
+ return auth.ok && auth.apiKey
314
+ ? { model, apiKey: auth.apiKey, headers: auth.headers }
315
+ : undefined;
316
+ }
317
+
318
+ async function getFastTldrModelAuth(
319
+ ctx: ExtensionContext,
320
+ configuredModel?: TldrModelPreference,
321
+ ): Promise<FastModelAuth | undefined> {
322
+ if (configuredModel) {
323
+ const configuredAuth = await getModelAuth(ctx, configuredModel);
324
+ if (configuredAuth) return configuredAuth;
325
+ }
326
+
327
+ for (const candidate of FAST_TLDR_MODELS) {
328
+ if (configuredModel && formatTldrModelKey(candidate) === formatTldrModelKey(configuredModel)) {
329
+ continue;
265
330
  }
331
+ const auth = await getModelAuth(ctx, candidate);
332
+ if (auth) return auth;
266
333
  }
267
334
 
268
- const text = extractTldrText(lines);
269
- return text ? truncateToWidth(text, maxWidth) : null;
335
+ return undefined;
336
+ }
337
+
338
+ function truncateText(text: string, maxChars: number): string {
339
+ if (text.length <= maxChars) return text;
340
+ return `${text.slice(0, maxChars - 1)}…`;
341
+ }
342
+
343
+ function extractTextContent(content: readonly unknown[] | undefined): string | undefined {
344
+ if (!content) return undefined;
345
+ const text = content
346
+ .map((part) => {
347
+ if (!part || typeof part !== "object") return undefined;
348
+ const record = part as Record<string, unknown>;
349
+ return record.type === "text" && typeof record.text === "string"
350
+ ? record.text
351
+ : undefined;
352
+ })
353
+ .filter((part): part is string => Boolean(part))
354
+ .join("\n");
355
+ return text.length > 0 ? text : undefined;
270
356
  }
271
357
 
272
358
  function prefixed(icon: string, text: string, preset: IconPreset): string {
@@ -274,6 +360,425 @@ function prefixed(icon: string, text: string, preset: IconPreset): string {
274
360
  return `${icon} ${text}`;
275
361
  }
276
362
 
363
+ class TldrFactCollector {
364
+ private nextIndex = 1;
365
+ private readonly activities: TldrActivity[] = [];
366
+
367
+ resetConversation(): void {
368
+ this.nextIndex = 1;
369
+ this.activities.splice(0);
370
+ }
371
+
372
+ recordUserMessage(prompt: string): TldrActivity {
373
+ return this.addActivity("user_message", "immediate", `User message: ${prompt}`);
374
+ }
375
+
376
+ recordAssistantUpdate(message: unknown): TldrActivity | undefined {
377
+ if (!message || typeof message !== "object") return undefined;
378
+ const record = message as Record<string, unknown>;
379
+ if (record.role !== "assistant") return undefined;
380
+ const text = extractTextContent(record.content as readonly unknown[] | undefined);
381
+ return text
382
+ ? this.addActivity("assistant_update", "normal", `Assistant update: ${text}`)
383
+ : undefined;
384
+ }
385
+
386
+ recordToolCall(event: { toolName: string }): TldrActivity {
387
+ return this.addActivity("tool_call", "normal", `Tool started: ${event.toolName}`);
388
+ }
389
+
390
+ recordToolResult(event: {
391
+ toolName: string;
392
+ isError?: boolean;
393
+ content?: readonly unknown[];
394
+ }): TldrActivity {
395
+ const resultText = extractTextContent(event.content);
396
+ const status = event.isError ? "error" : "ok";
397
+ return this.addActivity(
398
+ "tool_result",
399
+ "normal",
400
+ resultText
401
+ ? `Tool finished: ${event.toolName} (${status})\n${resultText}`
402
+ : `Tool finished: ${event.toolName} (${status})`,
403
+ );
404
+ }
405
+
406
+ recordMessageEnd(message: unknown): TldrActivity | "emptyFinalStop" | "ignored" {
407
+ if (!message || typeof message !== "object") return "ignored";
408
+ const record = message as Record<string, unknown>;
409
+ if (record.role !== "assistant") return "ignored";
410
+
411
+ const stopReason = typeof record.stopReason === "string" ? record.stopReason : undefined;
412
+ if (stopReason === "toolUse") return "ignored";
413
+
414
+ let text: string | undefined;
415
+ if (stopReason === "stop") {
416
+ const finalText = extractTextContent(record.content as readonly unknown[] | undefined);
417
+ if (!finalText) return "emptyFinalStop";
418
+ text = `Assistant final response: ${finalText}`;
419
+ } else if (stopReason) {
420
+ const error = typeof record.errorMessage === "string" ? record.errorMessage : undefined;
421
+ text = error
422
+ ? `Assistant finished with ${stopReason}: ${error}`
423
+ : `Assistant finished with ${stopReason}`;
424
+ }
425
+
426
+ return text
427
+ ? this.addActivity(
428
+ stopReason === "stop" ? "assistant_final" : "assistant_failure",
429
+ "final",
430
+ text,
431
+ )
432
+ : "ignored";
433
+ }
434
+
435
+ activitiesAfter(previousIndex: number, throughIndex: number): readonly TldrActivity[] {
436
+ return this.activities.filter(
437
+ (activity) => activity.index > previousIndex && activity.index <= throughIndex,
438
+ );
439
+ }
440
+
441
+ latestActivityIndex(): number {
442
+ return this.nextIndex - 1;
443
+ }
444
+
445
+ discardActivitiesThrough(activityIndex: number): void {
446
+ const firstRetainedIndex = this.activities.findIndex(
447
+ (activity) => activity.index > activityIndex,
448
+ );
449
+ if (firstRetainedIndex === -1) {
450
+ this.activities.splice(0);
451
+ return;
452
+ }
453
+ if (firstRetainedIndex > 0) this.activities.splice(0, firstRetainedIndex);
454
+ }
455
+
456
+ private addActivity(
457
+ activityType: TldrActivityType,
458
+ displayPriority: TldrDisplayPriority,
459
+ text: string,
460
+ ): TldrActivity {
461
+ const activity = {
462
+ index: this.nextIndex,
463
+ activityType,
464
+ displayPriority,
465
+ text: truncateText(text, MAX_ACTIVITY_TEXT_CHARS),
466
+ };
467
+ this.nextIndex++;
468
+ this.activities.push(activity);
469
+ if (this.activities.length > MAX_RETAINED_RAW_ACTIVITIES) {
470
+ this.activities.splice(0, this.activities.length - MAX_RETAINED_RAW_ACTIVITIES);
471
+ }
472
+ return activity;
473
+ }
474
+ }
475
+
476
+ class FooterTldrEngine {
477
+ private readonly facts = new TldrFactCollector();
478
+ private configuredModel?: TldrModelPreference;
479
+ private runId = 0;
480
+ private latestAcceptedActivityIndex = 0;
481
+ private lastRenderedActivityIndex = 0;
482
+ private lastRenderedText = "";
483
+ private lastDisplayAt = Number.NEGATIVE_INFINITY;
484
+ private readonly acceptedCheckpoints: TldrCheckpoint[] = [];
485
+ private checkpointQueue: TldrCheckpointJob[] = [];
486
+ private inFlightCheckpoint?: TldrCheckpointJob;
487
+ private pendingDisplayCheckpoint?: TldrCheckpoint;
488
+ private displayTimer?: ReturnType<typeof setTimeout>;
489
+ private abortController?: AbortController;
490
+ private currentText: string | null = null;
491
+
492
+ constructor(private readonly requestRender: () => void) {}
493
+
494
+ text(maxWidth: number): string | null {
495
+ return this.currentText ? truncateToWidth(this.currentText, maxWidth) : null;
496
+ }
497
+
498
+ startSession(cwd: string): void {
499
+ this.configuredModel = resolveTldrModelPreference(cwd);
500
+ this.facts.resetConversation();
501
+ this.startFreshRun();
502
+ }
503
+
504
+ shutdown(): void {
505
+ this.facts.resetConversation();
506
+ this.startFreshRun();
507
+ }
508
+
509
+ recordUserMessage(ctx: ExtensionContext, prompt: string): void {
510
+ this.currentText = null;
511
+ this.requestRender();
512
+ this.enqueue(ctx, this.facts.recordUserMessage(prompt));
513
+ }
514
+
515
+ recordAssistantUpdate(ctx: ExtensionContext, message: unknown): void {
516
+ const activity = this.facts.recordAssistantUpdate(message);
517
+ if (activity) this.enqueue(ctx, activity);
518
+ }
519
+
520
+ recordToolCall(ctx: ExtensionContext, event: { toolName: string }): void {
521
+ this.enqueue(ctx, this.facts.recordToolCall(event));
522
+ }
523
+
524
+ recordToolResult(
525
+ ctx: ExtensionContext,
526
+ event: { toolName: string; isError?: boolean; content?: readonly unknown[] },
527
+ ): void {
528
+ this.enqueue(ctx, this.facts.recordToolResult(event));
529
+ }
530
+
531
+ recordMessageEnd(ctx: ExtensionContext, message: unknown): void {
532
+ const result = this.facts.recordMessageEnd(message);
533
+ if (result === "ignored") return;
534
+ if (result === "emptyFinalStop") {
535
+ this.facts.resetConversation();
536
+ this.startFreshRun();
537
+ return;
538
+ }
539
+ this.enqueue(ctx, result);
540
+ }
541
+
542
+ private startFreshRun(): void {
543
+ this.runId++;
544
+ this.cancelWork();
545
+ this.latestAcceptedActivityIndex = 0;
546
+ this.lastRenderedActivityIndex = 0;
547
+ this.lastRenderedText = "";
548
+ this.lastDisplayAt = Number.NEGATIVE_INFINITY;
549
+ this.acceptedCheckpoints.splice(0);
550
+ this.pendingDisplayCheckpoint = undefined;
551
+ this.currentText = null;
552
+ this.requestRender();
553
+ }
554
+
555
+ private cancelWork(): void {
556
+ if (this.displayTimer) clearTimeout(this.displayTimer);
557
+ this.displayTimer = undefined;
558
+ this.checkpointQueue.splice(0);
559
+ this.inFlightCheckpoint = undefined;
560
+ this.abortController?.abort();
561
+ this.abortController = undefined;
562
+ }
563
+
564
+ private enqueue(ctx: ExtensionContext, activity: TldrActivity): void {
565
+ if (!ctx.hasUI) return;
566
+ const job = {
567
+ activityIndex: activity.index,
568
+ displayPriority: activity.displayPriority,
569
+ runId: this.runId,
570
+ };
571
+
572
+ if (job.displayPriority === "immediate") {
573
+ this.clearPendingDisplay();
574
+ this.lastRenderedText = "";
575
+ this.checkpointQueue.splice(0);
576
+ this.abortInFlightCheckpoint();
577
+ this.checkpointQueue.push(job);
578
+ } else if (job.displayPriority === "final") {
579
+ this.clearPendingDisplay();
580
+ this.checkpointQueue = this.checkpointQueue.filter(
581
+ (queued) => queued.displayPriority !== "normal",
582
+ );
583
+ if (this.inFlightCheckpoint?.displayPriority === "normal") {
584
+ this.abortInFlightCheckpoint();
585
+ }
586
+ this.checkpointQueue.push(job);
587
+ } else {
588
+ this.checkpointQueue = this.checkpointQueue.filter(
589
+ (queued) => queued.displayPriority !== "normal",
590
+ );
591
+ this.checkpointQueue.push(job);
592
+ }
593
+
594
+ this.pump(ctx);
595
+ }
596
+
597
+ private clearPendingDisplay(): void {
598
+ if (this.displayTimer) clearTimeout(this.displayTimer);
599
+ this.displayTimer = undefined;
600
+ this.pendingDisplayCheckpoint = undefined;
601
+ }
602
+
603
+ private abortInFlightCheckpoint(): void {
604
+ if (!this.inFlightCheckpoint) return;
605
+ this.abortController?.abort();
606
+ this.abortController = undefined;
607
+ this.inFlightCheckpoint = undefined;
608
+ }
609
+
610
+ private pump(ctx: ExtensionContext): void {
611
+ if (!ctx.hasUI || this.inFlightCheckpoint) return;
612
+ const job = this.checkpointQueue.shift();
613
+ if (!job) return;
614
+ if (job.runId !== this.runId || job.activityIndex <= this.latestAcceptedActivityIndex) {
615
+ this.pump(ctx);
616
+ return;
617
+ }
618
+ this.inFlightCheckpoint = job;
619
+ void this.runCheckpointRequest(ctx, job);
620
+ }
621
+
622
+ private isCurrentJob(job: TldrCheckpointJob): boolean {
623
+ return job.runId === this.runId && this.inFlightCheckpoint === job;
624
+ }
625
+
626
+ private async runCheckpointRequest(
627
+ ctx: ExtensionContext,
628
+ job: TldrCheckpointJob,
629
+ ): Promise<void> {
630
+ let abortController: AbortController | undefined;
631
+ try {
632
+ const prompt = this.checkpointPrompt(job);
633
+ if (!prompt) return;
634
+ const auth = await getFastTldrModelAuth(ctx, this.configuredModel);
635
+ if (!this.isCurrentJob(job) || !auth) return;
636
+
637
+ abortController = new AbortController();
638
+ this.abortController = abortController;
639
+ const response = await complete(
640
+ auth.model,
641
+ { systemPrompt: checkpointSystemPrompt(job), messages: [prompt] },
642
+ {
643
+ apiKey: auth.apiKey,
644
+ headers: auth.headers,
645
+ maxTokens: TLDR_MAX_TOKENS,
646
+ maxRetries: 0,
647
+ cacheRetention: "none",
648
+ timeoutMs: TLDR_REQUEST_TIMEOUT_MS,
649
+ signal: abortController.signal,
650
+ },
651
+ );
652
+ if (!this.isCurrentJob(job) || response.stopReason !== "stop") return;
653
+
654
+ const text = extractTextContent(response.content) ?? "";
655
+ if (!text) return;
656
+ const checkpoint = {
657
+ activityIndex: job.activityIndex,
658
+ displayPriority: job.displayPriority,
659
+ text,
660
+ };
661
+ this.acceptCheckpoint(checkpoint);
662
+ this.considerDisplayingCheckpoint(ctx, checkpoint);
663
+ } catch {
664
+ // Best effort. Later checkpoints include unaccepted raw activity.
665
+ } finally {
666
+ if (abortController && this.abortController === abortController) {
667
+ this.abortController = undefined;
668
+ }
669
+ if (this.inFlightCheckpoint === job) this.inFlightCheckpoint = undefined;
670
+ this.pump(ctx);
671
+ }
672
+ }
673
+
674
+ private acceptCheckpoint(checkpoint: TldrCheckpoint): void {
675
+ this.acceptedCheckpoints.push(checkpoint);
676
+ if (this.acceptedCheckpoints.length > MAX_CONTEXT_CHECKPOINTS) {
677
+ this.acceptedCheckpoints.splice(
678
+ 0,
679
+ this.acceptedCheckpoints.length - MAX_CONTEXT_CHECKPOINTS,
680
+ );
681
+ }
682
+ this.latestAcceptedActivityIndex = checkpoint.activityIndex;
683
+ this.facts.discardActivitiesThrough(checkpoint.activityIndex);
684
+ }
685
+
686
+ private considerDisplayingCheckpoint(
687
+ ctx: ExtensionContext,
688
+ checkpoint: TldrCheckpoint,
689
+ ): void {
690
+ if (checkpoint.activityIndex <= this.lastRenderedActivityIndex) return;
691
+ if (checkpoint.activityIndex !== this.facts.latestActivityIndex()) return;
692
+
693
+ if (checkpoint.displayPriority !== "normal" || !this.lastRenderedText) {
694
+ this.clearPendingDisplay();
695
+ this.renderCheckpoint(checkpoint);
696
+ return;
697
+ }
698
+
699
+ const elapsedMs = performance.now() - this.lastDisplayAt;
700
+ if (elapsedMs >= TLDR_DISPLAY_UPDATE_INTERVAL_MS) {
701
+ this.clearPendingDisplay();
702
+ this.renderCheckpoint(checkpoint);
703
+ return;
704
+ }
705
+
706
+ this.pendingDisplayCheckpoint = checkpoint;
707
+ if (this.displayTimer) return;
708
+ this.displayTimer = setTimeout(() => {
709
+ this.displayTimer = undefined;
710
+ const pending = this.pendingDisplayCheckpoint;
711
+ this.pendingDisplayCheckpoint = undefined;
712
+ if (!pending || pending.activityIndex !== this.facts.latestActivityIndex()) return;
713
+ this.renderCheckpoint(pending);
714
+ }, TLDR_DISPLAY_UPDATE_INTERVAL_MS - elapsedMs);
715
+ }
716
+
717
+ private renderCheckpoint(checkpoint: TldrCheckpoint): void {
718
+ if (checkpoint.activityIndex <= this.lastRenderedActivityIndex) return;
719
+ if (checkpoint.text === this.lastRenderedText) return;
720
+ this.lastRenderedActivityIndex = checkpoint.activityIndex;
721
+ this.lastRenderedText = checkpoint.text;
722
+ this.lastDisplayAt = performance.now();
723
+ this.currentText = checkpoint.text;
724
+ this.requestRender();
725
+ }
726
+
727
+ private checkpointPrompt(job: TldrCheckpointJob): UserMessage | undefined {
728
+ const rawActivities = this.facts.activitiesAfter(
729
+ this.latestAcceptedActivityIndex,
730
+ job.activityIndex,
731
+ );
732
+ if (rawActivities.length === 0) return undefined;
733
+ return {
734
+ role: "user",
735
+ content: [
736
+ {
737
+ type: "text",
738
+ text: [
739
+ "Previous generated TLDR checkpoints:",
740
+ previousCheckpointLines(this.acceptedCheckpoints),
741
+ "",
742
+ "New raw activity since the latest accepted checkpoint:",
743
+ ...rawActivities.map(formatRawActivity),
744
+ "",
745
+ `Write the next TLDR through activity ${job.activityIndex}.`,
746
+ ].join("\n"),
747
+ },
748
+ ],
749
+ timestamp: Date.now(),
750
+ };
751
+ }
752
+ }
753
+
754
+ function checkpointSystemPrompt(job: TldrCheckpointJob): string {
755
+ const tenseInstruction =
756
+ job.displayPriority === "final"
757
+ ? "Start with a past-tense verb."
758
+ : "Start with a present-tense -ing verb.";
759
+ return `Write one plain-English TLDR for a terminal coding agent.
760
+ Use the prior TLDRs for context and the new activity for the update.
761
+ Summarize only activity up to the requested index.
762
+ If context is sparse, still summarize the available activity.
763
+ Never ask for more information or say there is not enough context.
764
+ Return exactly one complete sentence under ${TLDR_TARGET_SUMMARY_CHARS} characters.
765
+ Use third person. Do not address the user.
766
+ Plain text only; no markdown, JSON, code, bullets, quotes, or file/tool names.
767
+ ${tenseInstruction}`;
768
+ }
769
+
770
+ function previousCheckpointLines(checkpoints: readonly TldrCheckpoint[]): string {
771
+ if (checkpoints.length === 0) return "none";
772
+ return checkpoints
773
+ .slice(-MAX_CONTEXT_CHECKPOINTS)
774
+ .map((checkpoint) => `- Through activity ${checkpoint.activityIndex}: ${checkpoint.text}`)
775
+ .join("\n");
776
+ }
777
+
778
+ function formatRawActivity(activity: TldrActivity): string {
779
+ return `[${activity.index}] ${activity.activityType}: ${activity.text}`;
780
+ }
781
+
277
782
  function shouldShowStatus(key: string, filter: StatusFilter): boolean {
278
783
  if (filter.mode === "only") return filter.shown.has(key);
279
784
  return !filter.hidden.has(key);
@@ -368,39 +873,9 @@ function getKnownStatusKeys(filter: StatusFilter, seenStatusKeys: Set<string>):
368
873
  export default function (pi: ExtensionAPI) {
369
874
  let requestRender: (() => void) | undefined;
370
875
  let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
371
- let capturedTldrWidget: CapturedTldrWidget | undefined;
372
876
  const seenStatusKeys = new Set<string>();
373
- const patchedUis = new WeakSet<object>();
374
877
  const refresh = () => requestRender?.();
375
- const installTldrWidgetCapture = (ctx: ExtensionContext) => {
376
- if (!ctx.hasUI) return;
377
- const ui = ctx.ui as ExtensionContext["ui"];
378
- if (patchedUis.has(ui)) return;
379
- patchedUis.add(ui);
380
-
381
- const originalSetWidget = ui.setWidget.bind(ui);
382
- ui.setWidget = ((key: string, content: unknown, options?: unknown) => {
383
- if (key === TLDR_WIDGET_KEY) {
384
- if (Array.isArray(content)) {
385
- capturedTldrWidget = { kind: "lines", lines: content };
386
- } else if (typeof content === "function") {
387
- capturedTldrWidget = {
388
- kind: "factory",
389
- factory: content as TldrWidgetFactory,
390
- };
391
- } else {
392
- capturedTldrWidget = undefined;
393
- }
394
- refresh();
395
- }
396
-
397
- return originalSetWidget(
398
- key,
399
- content as Parameters<ExtensionContext["ui"]["setWidget"]>[1],
400
- options as Parameters<ExtensionContext["ui"]["setWidget"]>[2],
401
- );
402
- }) as ExtensionContext["ui"]["setWidget"];
403
- };
878
+ const tldr = new FooterTldrEngine(refresh);
404
879
  const restoreStatusFilter = (ctx: ExtensionContext) => {
405
880
  let restoredFilter = readGlobalStatusFilter();
406
881
  if (!restoredFilter) {
@@ -587,16 +1062,29 @@ export default function (pi: ExtensionAPI) {
587
1062
  pi.on("model_select", async () => refresh());
588
1063
  pi.on("thinking_level_select", async () => refresh());
589
1064
  pi.on("turn_end", async () => refresh());
590
- pi.on("message_end", async () => refresh());
1065
+ pi.on("before_agent_start", async (event, ctx) => {
1066
+ tldr.recordUserMessage(ctx, event.prompt);
1067
+ });
1068
+ pi.on("message_update", async (event, ctx) => {
1069
+ tldr.recordAssistantUpdate(ctx, event.message);
1070
+ });
1071
+ pi.on("tool_call", async (event, ctx) => {
1072
+ tldr.recordToolCall(ctx, event);
1073
+ });
1074
+ pi.on("tool_result", async (event, ctx) => {
1075
+ tldr.recordToolResult(ctx, event);
1076
+ });
1077
+ pi.on("message_end", async (event, ctx) => {
1078
+ tldr.recordMessageEnd(ctx, event.message);
1079
+ refresh();
1080
+ });
591
1081
  pi.on("session_tree", async (_event, ctx) => {
592
- installTldrWidgetCapture(ctx);
593
1082
  restoreStatusFilter(ctx);
594
1083
  refresh();
595
1084
  });
596
1085
 
597
1086
  pi.on("session_start", async (_event, ctx) => {
598
- capturedTldrWidget = undefined;
599
- installTldrWidgetCapture(ctx);
1087
+ tldr.startSession(ctx.cwd);
600
1088
  restoreStatusFilter(ctx);
601
1089
 
602
1090
  if (!ctx.hasUI) return;
@@ -633,13 +1121,7 @@ export default function (pi: ExtensionAPI) {
633
1121
  const contextText = usage
634
1122
  ? `${usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "—%"} / ${formatTokens(usage.contextWindow)}`
635
1123
  : "—";
636
- const tldrText = renderCapturedTldr(
637
- capturedTldrWidget,
638
- tui,
639
- theme,
640
- width,
641
- tldrMaxWidth,
642
- );
1124
+ const tldrText = tldr.text(tldrMaxWidth);
643
1125
 
644
1126
  const segmentRenderers: Record<SegmentName, string | null> = {
645
1127
  model: theme.fg(
@@ -678,7 +1160,7 @@ export default function (pi: ExtensionAPI) {
678
1160
  });
679
1161
 
680
1162
  pi.on("session_shutdown", async (_event, ctx) => {
681
- capturedTldrWidget = undefined;
1163
+ tldr.shutdown();
682
1164
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
683
1165
  });
684
1166
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-bar",
3
- "version": "0.3.6",
4
- "description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, context usage, pi-tldr, and extension statuses.",
3
+ "version": "0.3.7",
4
+ "description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, context usage, live TLDR, and extension statuses.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -14,8 +14,7 @@
14
14
  "thinking",
15
15
  "context",
16
16
  "context-window",
17
- "tldr",
18
- "pi-tldr"
17
+ "tldr"
19
18
  ],
20
19
  "license": "MIT",
21
20
  "repository": {
@@ -33,10 +32,14 @@
33
32
  "LICENSE"
34
33
  ],
35
34
  "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*",
36
36
  "@earendil-works/pi-coding-agent": "*",
37
37
  "@earendil-works/pi-tui": "*"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
+ "@earendil-works/pi-ai": {
41
+ "optional": true
42
+ },
40
43
  "@earendil-works/pi-coding-agent": {
41
44
  "optional": true
42
45
  },
@@ -48,6 +51,6 @@
48
51
  "extensions": [
49
52
  "./extensions/status-footer.ts"
50
53
  ],
51
- "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.6/assets/screenshot.png"
54
+ "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.7/assets/screenshot.png"
52
55
  }
53
56
  }