pi-zentui 0.10.1 → 0.11.0

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
@@ -275,7 +275,7 @@ Default config values — copy this and change any value you want:
275
275
 
276
276
  - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b` / `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`). Short `#rgb` hex values expand to `#rrggbb`.
277
277
  - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling. Values `1..4999` clamp up to `5000` (minimum 5s); invalid/non-finite values fall back to `30000`.
278
- - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment.
278
+ - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
279
279
  - `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Layout** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
280
280
  - `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
281
281
  - `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **Layout** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
@@ -19,6 +19,7 @@ import {
19
19
  formatUsernameHostLabel,
20
20
  } from "./format";
21
21
  import { resolveRuntimeSymbol } from "./icons";
22
+ import type { LiveContextOverride } from "./live-context";
22
23
  import type { FooterState } from "./state";
23
24
  import { renderStyleForSource } from "./style";
24
25
 
@@ -155,6 +156,7 @@ export function installFooter(
155
156
  setRequestRender: (fn: (() => void) | undefined) => void;
156
157
  scheduleProjectRefresh: (ctx: ExtensionContext) => void;
157
158
  setExtensionStatusesGetter?: (fn: (() => ReadonlyMap<string, string>) | undefined) => void;
159
+ getLiveContext?: () => LiveContextOverride | undefined;
158
160
  },
159
161
  ): void {
160
162
  ctx.ui.setFooter((tui, theme, footerData) => {
@@ -198,14 +200,20 @@ export function installFooter(
198
200
  ? formatGitBranchText(branch, config.gitBranch.maxLength)
199
201
  : undefined;
200
202
  const contextUsage = ctx.getContextUsage();
203
+ const liveContext = hooks.getLiveContext?.();
201
204
  const contextWindow = ctx.model?.contextWindow ?? contextUsage?.contextWindow;
205
+ const useLiveContext =
206
+ liveContext !== undefined && contextWindow !== undefined && contextWindow > 0;
207
+ const contextPercent = useLiveContext
208
+ ? (liveContext.tokens / contextWindow) * 100
209
+ : contextUsage?.percent;
202
210
  const contextLabel = buildContextDisplayLabel({
203
- percent: contextUsage?.percent,
211
+ percent: contextPercent,
204
212
  contextWindow,
205
213
  style: config.contextStyle,
206
214
  asciiGauge: iconMode === "ascii",
207
215
  });
208
- const tier = contextColorTier(contextUsage?.percent, config.contextThresholds);
216
+ const tier = contextColorTier(contextPercent, config.contextThresholds);
209
217
  const contextColor =
210
218
  tier === "error"
211
219
  ? config.colors.contextError
@@ -41,6 +41,7 @@ import {
41
41
  import { installFooter } from "./footer";
42
42
  import { buildSessionDurationLabel, invalidateUsageTotalsCache } from "./format";
43
43
  import { emptyGitStatus, readGitStatus } from "./git";
44
+ import { LiveContextController } from "./live-context";
44
45
  import { readPackageVersionResult } from "./package-version";
45
46
  import {
46
47
  createProjectRefreshScheduler,
@@ -113,6 +114,7 @@ export default function (pi: ExtensionAPI) {
113
114
  const refresh = () => {
114
115
  if (sessionLifecycle.isCurrent()) requestFooterRender?.();
115
116
  };
117
+ const liveContext = new LiveContextController(sessionLifecycle, refresh);
116
118
  const getActiveTheme = () => activeTheme;
117
119
  const getCurrentConfig = () => currentConfig;
118
120
  const getThinkingLevel = () =>
@@ -330,6 +332,7 @@ export default function (pi: ExtensionAPI) {
330
332
  setExtensionStatusesGetter(fn) {
331
333
  getActiveExtensionStatuses = fn ?? (() => new Map());
332
334
  },
335
+ getLiveContext: () => liveContext.get(),
333
336
  });
334
337
  footerInstalled = true;
335
338
  stopProjectRefresh();
@@ -442,6 +445,7 @@ export default function (pi: ExtensionAPI) {
442
445
 
443
446
  pi.on("session_start", async (_event, ctx) => {
444
447
  sessionLifecycle.start();
448
+ liveContext.clear();
445
449
  state.sessionStartEpoch = Date.now();
446
450
  invalidateUsageTotalsCache();
447
451
  lastProjectCwd = undefined;
@@ -510,6 +514,7 @@ export default function (pi: ExtensionAPI) {
510
514
  });
511
515
 
512
516
  pi.on("session_shutdown", async (_event, ctx) => {
517
+ liveContext.clear();
513
518
  cleanupUi(ctx);
514
519
  });
515
520
 
@@ -518,12 +523,44 @@ export default function (pi: ExtensionAPI) {
518
523
  refreshInteractiveState(ctx, true);
519
524
  };
520
525
 
521
- pi.on("agent_start", syncInteractiveState);
522
- pi.on("agent_end", syncInteractiveAndProjectState);
523
- pi.on("model_select", syncInteractiveState);
526
+ pi.on("agent_start", (event, ctx) => {
527
+ liveContext.clear();
528
+ syncInteractiveState(event, ctx);
529
+ });
530
+ pi.on("agent_end", (event, ctx) => {
531
+ liveContext.clear();
532
+ syncInteractiveAndProjectState(event, ctx);
533
+ });
534
+ pi.on("model_select", (event, ctx) => {
535
+ liveContext.clear();
536
+ syncInteractiveState(event, ctx);
537
+ });
524
538
  pi.on("thinking_level_select", syncInteractiveState);
525
- pi.on("message_end", syncInteractiveAndProjectStateWithUsage);
539
+ pi.on("message_update", (event) => {
540
+ liveContext.update(event.message);
541
+ });
542
+ pi.on("message_end", (event, ctx) => {
543
+ // Pi notifies extensions before persisting a successful message, so retain its live
544
+ // context until agent_end; failed messages clear immediately instead of showing stale usage.
545
+ if (
546
+ event.message.role === "assistant" &&
547
+ (event.message.stopReason === "error" || event.message.stopReason === "aborted")
548
+ ) {
549
+ liveContext.clear();
550
+ }
551
+ syncInteractiveAndProjectStateWithUsage(event, ctx);
552
+ });
553
+ pi.on("tool_execution_start", (event, ctx) => {
554
+ liveContext.clear();
555
+ syncInteractiveState(event, ctx);
556
+ });
526
557
  pi.on("tool_execution_end", syncInteractiveAndProjectState);
527
- pi.on("session_compact", syncInteractiveAndProjectStateWithUsage);
528
- pi.on("session_tree", syncInteractiveAndProjectStateWithUsage);
558
+ pi.on("session_compact", (event, ctx) => {
559
+ liveContext.clear();
560
+ syncInteractiveAndProjectStateWithUsage(event, ctx);
561
+ });
562
+ pi.on("session_tree", (event, ctx) => {
563
+ liveContext.clear();
564
+ syncInteractiveAndProjectStateWithUsage(event, ctx);
565
+ });
529
566
  }
@@ -0,0 +1,75 @@
1
+ import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
2
+ import type { SessionLifecycle } from "./session-lifecycle";
3
+
4
+ export type LiveContextOverride = {
5
+ tokens: number;
6
+ };
7
+
8
+ function usageComponent(value: unknown): number {
9
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
10
+ }
11
+
12
+ export function calculateLiveContextTokens(usage: Usage | undefined): number | undefined {
13
+ if (!usage) return undefined;
14
+ const totalTokens = usageComponent(usage.totalTokens);
15
+ if (totalTokens > 0) return totalTokens;
16
+ const calculated =
17
+ usageComponent(usage.input) +
18
+ usageComponent(usage.output) +
19
+ usageComponent(usage.cacheRead) +
20
+ usageComponent(usage.cacheWrite);
21
+ return calculated > 0 ? calculated : undefined;
22
+ }
23
+
24
+ export function liveContextFromMessage(message: unknown): LiveContextOverride | undefined {
25
+ if (!message || typeof message !== "object") return undefined;
26
+ const assistant = message as Partial<AssistantMessage>;
27
+ if (assistant.role !== "assistant") return undefined;
28
+ if (assistant.stopReason === "error" || assistant.stopReason === "aborted") return undefined;
29
+ const tokens = calculateLiveContextTokens(assistant.usage);
30
+ return tokens === undefined ? undefined : { tokens };
31
+ }
32
+
33
+ export class LiveContextController {
34
+ private readonly lifecycle: SessionLifecycle;
35
+ private readonly requestRender: () => void;
36
+ private override: LiveContextOverride | undefined;
37
+ private cancelScheduledRender: (() => void) | undefined;
38
+ private scheduledGeneration: number | undefined;
39
+
40
+ constructor(lifecycle: SessionLifecycle, requestRender: () => void) {
41
+ this.lifecycle = lifecycle;
42
+ this.requestRender = requestRender;
43
+ }
44
+
45
+ get(): LiveContextOverride | undefined {
46
+ return this.override;
47
+ }
48
+
49
+ update(message: unknown): boolean {
50
+ const next = liveContextFromMessage(message);
51
+ if (!next || !this.lifecycle.isCurrent()) return false;
52
+ this.override = next;
53
+ const generation = this.lifecycle.currentGeneration();
54
+ if (this.cancelScheduledRender && this.scheduledGeneration !== generation) {
55
+ this.cancelScheduledRender = undefined;
56
+ this.scheduledGeneration = undefined;
57
+ }
58
+ if (!this.cancelScheduledRender) {
59
+ this.scheduledGeneration = generation;
60
+ this.cancelScheduledRender = this.lifecycle.defer(() => {
61
+ this.cancelScheduledRender = undefined;
62
+ this.scheduledGeneration = undefined;
63
+ if (this.override) this.requestRender();
64
+ }, 250);
65
+ }
66
+ return true;
67
+ }
68
+
69
+ clear(): void {
70
+ this.override = undefined;
71
+ this.cancelScheduledRender?.();
72
+ this.cancelScheduledRender = undefined;
73
+ this.scheduledGeneration = undefined;
74
+ }
75
+ }
@@ -18,14 +18,21 @@ export class SessionLifecycle {
18
18
  return this.active && generation === this.generation;
19
19
  }
20
20
 
21
- defer(callback: () => void): void {
22
- if (!this.active) return;
21
+ defer(callback: () => void, delayMs = 0): () => void {
22
+ if (!this.active) return () => {};
23
23
  const generation = this.generation;
24
+ let canceled = false;
24
25
  const timeout = setTimeout(() => {
25
26
  this.timeouts.delete(timeout);
26
- if (this.isCurrent(generation)) callback();
27
- }, 0);
27
+ if (!canceled && this.isCurrent(generation)) callback();
28
+ }, delayMs);
28
29
  this.timeouts.add(timeout);
30
+ return () => {
31
+ if (canceled) return;
32
+ canceled = true;
33
+ clearTimeout(timeout);
34
+ this.timeouts.delete(timeout);
35
+ };
29
36
  }
30
37
 
31
38
  queueMicrotask(callback: () => void): () => void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",