pi-bar 0.3.5 → 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
@@ -3,22 +3,23 @@
3
3
  [![npm version](https://img.shields.io/npm/v/pi-bar.svg)](https://www.npmjs.com/package/pi-bar)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/pi-bar.svg)](https://www.npmjs.com/package/pi-bar)
5
5
 
6
- **Never accidentally run Opus on a typo again.** pi-bar puts the active model, thinking level, context usage, and extension statuses in pi's footer so you catch wrong-model / wrong-reasoning / context-pressure problems before they cost you.
6
+ **Never accidentally run Opus on a typo again.** pi-bar puts the active model, thinking level, context usage, live TLDR, and extension statuses in pi's footer so you catch wrong-model / wrong-reasoning / context-pressure problems before they cost you.
7
7
 
8
8
  ```text
9
- ◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M ❯ ✧ plan:active
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.5/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 live TLDR inline** — pi-bar can generate a compact live summary directly in the footer.
19
20
  - **Keep extension statuses visible** — statuses set with `ctx.ui.setStatus()` still appear in the custom footer.
20
21
 
21
- 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 / extension-status line that refreshes when model, thinking level, context usage, 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.
22
23
 
23
24
  ## Install
24
25
 
@@ -39,11 +40,29 @@ pi-bar works out of the box, but you can tune it with environment variables befo
39
40
  ### Show or hide segments
40
41
 
41
42
  ```bash
42
- PI_BAR_SHOW=model,thinking,context,extensions pi
43
+ PI_BAR_SHOW=model,thinking,context,tldr,extensions pi
43
44
  PI_BAR_SHOW=model,context pi
44
45
  ```
45
46
 
46
- Allowed segments are `model`, `thinking`, `context`, and `extensions`. 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
+
49
+ ### Configure live TLDR
50
+
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
+
53
+ ```bash
54
+ PI_BAR_TLDR_MAX_WIDTH=48 pi
55
+ ```
56
+
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:
60
+
61
+ ```bash
62
+ PI_BAR_TLDR_MODEL=anthropic/claude-haiku-4-5 pi
63
+ ```
64
+
65
+ You can also set `bar.tldrModel` in pi settings; `tldr.model` is accepted for compatibility with existing settings.
47
66
 
48
67
  ### Configure extension statuses
49
68
 
@@ -76,10 +95,10 @@ PI_BAR_ICONS=nerdfont pi
76
95
 
77
96
  Supported presets:
78
97
 
79
- - `unicode` — default, e.g. `◈ gpt-5.5 ❯ ✦ think:medium ❯ ◷ 42.7% / 272k ❯ ✧ plan:active`
80
- - `plain` — maximum compatibility, e.g. `model:gpt-5.5 | think:medium | ctx:42.7% / 272k | ext:plan:active`
81
- - `emoji` — colorful emoji markers, e.g. `🤖 gpt-5.5 › ✨ think:medium › 🧭 42.7% / 272k › 🧩 plan:active`
82
- - `nerdfont` — icon-font glyphs for terminals configured with a Nerd Font, e.g. `󰚩 gpt-5.5  󰧑 think:medium  󰍛 42.7% / 272k  󰐱 plan:active`
98
+ - `unicode` — default, e.g. `◈ gpt-5.5 ❯ ✦ think:medium ❯ ◷ 42.7% / 272k ❯ ⓘ Reading files ❯ ✧ plan:active`
99
+ - `plain` — maximum compatibility, e.g. `model:gpt-5.5 | think:medium | ctx:42.7% / 272k | tldr:Reading files | ext:plan:active`
100
+ - `emoji` — colorful emoji markers, e.g. `🤖 gpt-5.5 › ✨ think:medium › 🧭 42.7% / 272k › 📝 Reading files › 🧩 plan:active`
101
+ - `nerdfont` — icon-font glyphs for terminals configured with a Nerd Font, e.g. `󰚩 gpt-5.5  󰧑 think:medium  󰍛 42.7% / 272k  󰦨 Reading files  󰐱 plan:active`
83
102
 
84
103
  ## Compatibility
85
104
 
@@ -97,3 +116,5 @@ PI_BAR_ICONS=plain pi
97
116
  ## Security note
98
117
 
99
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.
@@ -2,7 +2,7 @@
2
2
  * pi-bar — footer/statusline extension.
3
3
  *
4
4
  * Replaces the built-in footer with left-aligned segments:
5
- * ◈ <model name> ❯ ✦ think:<level> ❯ ◷ <context% / window> ❯ ✧ <extension statuses>
5
+ * ◈ <model name> ❯ ✦ think:<level> ❯ ◷ <context% / window> ❯ ⓘ <tldr> ❯ ✧ <extension statuses>
6
6
  *
7
7
  * Examples:
8
8
  * ◈ claude-opus-4.7 ❯ ✦ think:med ❯ ◷ 2.6% / 1.0M
@@ -11,18 +11,22 @@
11
11
  * and after each assistant turn so context usage stays current.
12
12
  *
13
13
  * Configuration env vars:
14
- * PI_BAR_SHOW=model,thinking,context,extensions
14
+ * PI_BAR_SHOW=model,thinking,context,tldr,extensions
15
15
  * PI_BAR_THRESHOLDS=70,90
16
16
  * PI_BAR_ICONS=unicode|plain|emoji|nerdfont
17
+ * PI_BAR_TLDR_MAX_WIDTH=64
17
18
  */
18
19
 
19
20
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
21
  import { homedir } from "node:os";
21
22
  import { dirname, join } from "node:path";
23
+ import { complete, type UserMessage } from "@earendil-works/pi-ai";
22
24
  import {
23
25
  getSettingsListTheme,
26
+ SettingsManager,
24
27
  type ExtensionAPI,
25
28
  type ExtensionContext,
29
+ type ThemeColor,
26
30
  } from "@earendil-works/pi-coding-agent";
27
31
  import {
28
32
  Container,
@@ -31,7 +35,7 @@ import {
31
35
  truncateToWidth,
32
36
  } from "@earendil-works/pi-tui";
33
37
 
34
- type SegmentName = "model" | "thinking" | "context" | "extensions";
38
+ type SegmentName = "model" | "thinking" | "context" | "tldr" | "extensions";
35
39
  type IconPreset = "unicode" | "plain" | "emoji" | "nerdfont";
36
40
  type StatusFilter =
37
41
  | { mode: "all"; hidden: Set<string> }
@@ -39,8 +43,48 @@ type StatusFilter =
39
43
  type SerializedStatusFilter =
40
44
  | { mode: "all"; hidden: string[] }
41
45
  | { mode: "only"; shown: string[] };
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>;
75
+ };
42
76
 
43
77
  const STATUS_FILTER_ENTRY_TYPE = "pi-bar-status-filter";
78
+ const DEFAULT_TLDR_MAX_WIDTH = 64;
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;
44
88
  const CONFIG_PATH =
45
89
  process.env.PI_BAR_CONFIG ?? join(homedir(), ".pi", "agent", "pi-bar.json");
46
90
 
@@ -48,6 +92,7 @@ const DEFAULT_SEGMENTS: SegmentName[] = [
48
92
  "model",
49
93
  "thinking",
50
94
  "context",
95
+ "tldr",
51
96
  "extensions",
52
97
  ];
53
98
  const DEFAULT_WARNING_THRESHOLD = 70;
@@ -59,6 +104,7 @@ const ICON_PRESETS: Record<
59
104
  model: string;
60
105
  thinking: string;
61
106
  context: string;
107
+ tldr: string;
62
108
  extensions: string;
63
109
  separator: string;
64
110
  }
@@ -67,6 +113,7 @@ const ICON_PRESETS: Record<
67
113
  model: "◈",
68
114
  thinking: "✦",
69
115
  context: "◷",
116
+ tldr: "ⓘ",
70
117
  extensions: "✧",
71
118
  separator: "❯",
72
119
  },
@@ -74,6 +121,7 @@ const ICON_PRESETS: Record<
74
121
  model: "model",
75
122
  thinking: "think",
76
123
  context: "ctx",
124
+ tldr: "tldr",
77
125
  extensions: "ext",
78
126
  separator: "|",
79
127
  },
@@ -81,6 +129,7 @@ const ICON_PRESETS: Record<
81
129
  model: "🤖",
82
130
  thinking: "✨",
83
131
  context: "🧭",
132
+ tldr: "📝",
84
133
  extensions: "🧩",
85
134
  separator: "›",
86
135
  },
@@ -88,6 +137,7 @@ const ICON_PRESETS: Record<
88
137
  model: "󰚩",
89
138
  thinking: "󰧑",
90
139
  context: "󰍛",
140
+ tldr: "󰦨",
91
141
  extensions: "󰐱",
92
142
  separator: "",
93
143
  },
@@ -111,7 +161,7 @@ function formatModelName(id: string | undefined): string {
111
161
  return base.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "");
112
162
  }
113
163
 
114
- function thinkingColor(level: string): string {
164
+ function thinkingColor(level: string): ThemeColor {
115
165
  switch (level) {
116
166
  case "off":
117
167
  return "thinkingOff";
@@ -137,7 +187,7 @@ function contextColor(
137
187
  percent: number | null | undefined,
138
188
  warningThreshold: number,
139
189
  errorThreshold: number,
140
- ): string {
190
+ ): ThemeColor {
141
191
  if (percent === null || percent === undefined) return "muted";
142
192
  if (percent >= errorThreshold) return "error";
143
193
  if (percent >= warningThreshold) return "warning";
@@ -152,7 +202,7 @@ function parseSegments(): SegmentName[] {
152
202
  .split(",")
153
203
  .map((segment) => segment.trim().toLowerCase())
154
204
  .filter((segment): segment is SegmentName =>
155
- ["model", "thinking", "context", "extensions"].includes(segment),
205
+ ["model", "thinking", "context", "tldr", "extensions"].includes(segment),
156
206
  );
157
207
 
158
208
  return requested.length > 0 ? requested : DEFAULT_SEGMENTS;
@@ -192,11 +242,543 @@ function parseIconPreset(): IconPreset {
192
242
  return "unicode";
193
243
  }
194
244
 
245
+ function parseTldrMaxWidth(): number {
246
+ const raw = process.env.PI_BAR_TLDR_MAX_WIDTH;
247
+ if (!raw) return DEFAULT_TLDR_MAX_WIDTH;
248
+
249
+ const value = Number.parseInt(raw, 10);
250
+ if (Number.isFinite(value) && value >= 10 && value <= 500) return value;
251
+ return DEFAULT_TLDR_MAX_WIDTH;
252
+ }
253
+
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) };
260
+ }
261
+
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
+ }
274
+
275
+ return undefined;
276
+ }
277
+
278
+ function resolveTldrModelPreference(cwd: string): TldrModelPreference | undefined {
279
+ const envModel = process.env.PI_BAR_TLDR_MODEL;
280
+ if (envModel) return parseTldrModelSpec(envModel);
281
+
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;
330
+ }
331
+ const auth = await getModelAuth(ctx, candidate);
332
+ if (auth) return auth;
333
+ }
334
+
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;
356
+ }
357
+
195
358
  function prefixed(icon: string, text: string, preset: IconPreset): string {
196
359
  if (preset === "plain") return `${icon}:${text}`;
197
360
  return `${icon} ${text}`;
198
361
  }
199
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
+
200
782
  function shouldShowStatus(key: string, filter: StatusFilter): boolean {
201
783
  if (filter.mode === "only") return filter.shown.has(key);
202
784
  return !filter.hidden.has(key);
@@ -293,6 +875,7 @@ export default function (pi: ExtensionAPI) {
293
875
  let statusFilter: StatusFilter = { mode: "all", hidden: new Set() };
294
876
  const seenStatusKeys = new Set<string>();
295
877
  const refresh = () => requestRender?.();
878
+ const tldr = new FooterTldrEngine(refresh);
296
879
  const restoreStatusFilter = (ctx: ExtensionContext) => {
297
880
  let restoredFilter = readGlobalStatusFilter();
298
881
  if (!restoredFilter) {
@@ -479,13 +1062,29 @@ export default function (pi: ExtensionAPI) {
479
1062
  pi.on("model_select", async () => refresh());
480
1063
  pi.on("thinking_level_select", async () => refresh());
481
1064
  pi.on("turn_end", async () => refresh());
482
- 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
+ });
483
1081
  pi.on("session_tree", async (_event, ctx) => {
484
1082
  restoreStatusFilter(ctx);
485
1083
  refresh();
486
1084
  });
487
1085
 
488
1086
  pi.on("session_start", async (_event, ctx) => {
1087
+ tldr.startSession(ctx.cwd);
489
1088
  restoreStatusFilter(ctx);
490
1089
 
491
1090
  if (!ctx.hasUI) return;
@@ -494,6 +1093,7 @@ export default function (pi: ExtensionAPI) {
494
1093
  const iconPreset = parseIconPreset();
495
1094
  const icons = ICON_PRESETS[iconPreset];
496
1095
  const { warningThreshold, errorThreshold } = parseThresholds();
1096
+ const tldrMaxWidth = parseTldrMaxWidth();
497
1097
 
498
1098
  ctx.ui.setFooter((tui, theme, footerData) => {
499
1099
  requestRender = () => tui.requestRender();
@@ -521,6 +1121,7 @@ export default function (pi: ExtensionAPI) {
521
1121
  const contextText = usage
522
1122
  ? `${usage.percent !== null ? `${usage.percent.toFixed(1)}%` : "—%"} / ${formatTokens(usage.contextWindow)}`
523
1123
  : "—";
1124
+ const tldrText = tldr.text(tldrMaxWidth);
524
1125
 
525
1126
  const segmentRenderers: Record<SegmentName, string | null> = {
526
1127
  model: theme.fg(
@@ -535,6 +1136,9 @@ export default function (pi: ExtensionAPI) {
535
1136
  contextSegmentColor,
536
1137
  prefixed(icons.context, contextText, iconPreset),
537
1138
  ),
1139
+ tldr: tldrText
1140
+ ? theme.fg("muted", prefixed(icons.tldr, tldrText, iconPreset))
1141
+ : null,
538
1142
  extensions: extensionStatuses
539
1143
  ? theme.fg(
540
1144
  "muted",
@@ -556,6 +1160,7 @@ export default function (pi: ExtensionAPI) {
556
1160
  });
557
1161
 
558
1162
  pi.on("session_shutdown", async (_event, ctx) => {
1163
+ tldr.shutdown();
559
1164
  if (ctx.hasUI) ctx.ui.setFooter(undefined);
560
1165
  });
561
1166
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-bar",
3
- "version": "0.3.5",
4
- "description": "Never accidentally run Opus on a typo. Pi footer/statusline showing active model, thinking level, context usage, 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",
@@ -13,7 +13,8 @@
13
13
  "model",
14
14
  "thinking",
15
15
  "context",
16
- "context-window"
16
+ "context-window",
17
+ "tldr"
17
18
  ],
18
19
  "license": "MIT",
19
20
  "repository": {
@@ -31,10 +32,14 @@
31
32
  "LICENSE"
32
33
  ],
33
34
  "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*",
34
36
  "@earendil-works/pi-coding-agent": "*",
35
37
  "@earendil-works/pi-tui": "*"
36
38
  },
37
39
  "peerDependenciesMeta": {
40
+ "@earendil-works/pi-ai": {
41
+ "optional": true
42
+ },
38
43
  "@earendil-works/pi-coding-agent": {
39
44
  "optional": true
40
45
  },
@@ -46,6 +51,6 @@
46
51
  "extensions": [
47
52
  "./extensions/status-footer.ts"
48
53
  ],
49
- "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.5/assets/screenshot.png"
54
+ "image": "https://cdn.jsdelivr.net/npm/pi-bar@0.3.7/assets/screenshot.png"
50
55
  }
51
56
  }