@zigai/pi-footer 0.1.6 → 0.1.8

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
@@ -1,10 +1,14 @@
1
1
  # Pi Footer
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@zigai/pi-footer.svg?color=blue)](https://www.npmjs.com/package/@zigai/pi-footer)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@zigai/pi-footer.svg)](https://www.npmjs.com/package/@zigai/pi-footer)
5
+ [![license](https://img.shields.io/npm/l/@zigai/pi-footer.svg)](../../LICENSE)
6
+
3
7
  This Pi extension replaces Pi's footer with a single compact plain-text status line.
4
8
 
5
9
  ![Pi Footer screenshot](assets/footer.png)
6
10
 
7
- The footer keeps key session information visible without taking much space:
11
+ Footer contents:
8
12
 
9
13
  - current working directory
10
14
  - git branch
@@ -12,10 +16,13 @@ The footer keeps key session information visible without taking much space:
12
16
  - thinking level
13
17
  - MCP status
14
18
  - context usage
15
- - a short post-run summary with total agent time and output token speed
16
19
 
17
20
  ## Install
18
21
 
19
22
  ```sh
20
23
  pi install npm:@zigai/pi-footer
21
24
  ```
25
+
26
+ ## License
27
+
28
+ MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zigai/pi-footer",
3
- "version": "0.1.6",
4
- "description": "Pi package for a custom footer and worked-for token speed widget.",
3
+ "version": "0.1.8",
4
+ "description": "Pi package that replaces Pi's footer with a compact status line.",
5
5
  "keywords": [
6
6
  "footer",
7
7
  "pi",
package/src/constants.ts CHANGED
@@ -1,5 +1,3 @@
1
- export const WIDGET_KEY = "worked-for-widget";
2
-
3
1
  export const ACTIVE_FOOTER_VARIANT = "plain" as const;
4
2
  export const BRANCH_ICON = "";
5
3
 
@@ -0,0 +1,41 @@
1
+ export const FOOTER_COMPONENT_MARKER = Symbol.for("zigai.pi-footer.component");
2
+ export const FOOTER_COMPONENT_KIND = Symbol.for("zigai.pi-footer.component-kind");
3
+
4
+ export type FooterComponentKind = "live" | "bridge";
5
+
6
+ export type MarkedFooterComponent = {
7
+ [FOOTER_COMPONENT_MARKER]: true;
8
+ [FOOTER_COMPONENT_KIND]: FooterComponentKind;
9
+ };
10
+
11
+ function isObject(value: unknown): value is object {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+
15
+ export function markFooterComponent<T extends object>(
16
+ component: T,
17
+ kind: FooterComponentKind,
18
+ ): T & MarkedFooterComponent {
19
+ Object.defineProperty(component, FOOTER_COMPONENT_MARKER, {
20
+ configurable: false,
21
+ enumerable: false,
22
+ value: true,
23
+ });
24
+ Object.defineProperty(component, FOOTER_COMPONENT_KIND, {
25
+ configurable: true,
26
+ enumerable: false,
27
+ value: kind,
28
+ });
29
+ return component as T & MarkedFooterComponent;
30
+ }
31
+
32
+ export function getFooterComponentKind(value: unknown): FooterComponentKind | undefined {
33
+ if (!isObject(value)) return undefined;
34
+
35
+ const candidate = value as Partial<MarkedFooterComponent>;
36
+ if (candidate[FOOTER_COMPONENT_MARKER] !== true) return undefined;
37
+
38
+ const kind = candidate[FOOTER_COMPONENT_KIND];
39
+ if (kind === "live" || kind === "bridge") return kind;
40
+ return undefined;
41
+ }
@@ -1,4 +1,3 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
2
 
4
3
  import {
@@ -11,6 +10,7 @@ import {
11
10
  } from "./constants.ts";
12
11
  import type {
13
12
  ContextUsage,
13
+ FooterContext,
14
14
  FooterData,
15
15
  FooterItem,
16
16
  FooterKey,
@@ -128,7 +128,7 @@ function getContextText(usage: ContextUsage, fallbackWindow?: number): string {
128
128
  return `${contextPercent.toFixed(1)}%/${formatTokens(contextWindow)}`;
129
129
  }
130
130
 
131
- function getMcpText(ctx: ExtensionContext, footerData: FooterData): string | null {
131
+ function getMcpText(ctx: FooterContext, footerData: FooterData): string | null {
132
132
  const statuses = Array.from(footerData.getExtensionStatuses().values())
133
133
  .map(sanitizeStatusText)
134
134
  .filter((status) => status.length > 0);
@@ -136,7 +136,7 @@ function getMcpText(ctx: ExtensionContext, footerData: FooterData): string | nul
136
136
  const mcpStatus = statuses.find((status) => /^MCP:/i.test(status));
137
137
  if (mcpStatus !== undefined && mcpStatus.length > 0) return mcpStatus;
138
138
 
139
- const serverCount = (ctx as ExtensionContext & { mcpServers?: unknown[] }).mcpServers?.length;
139
+ const serverCount = ctx.mcpServers?.length;
140
140
  if (typeof serverCount === "number") {
141
141
  return `MCP: ${serverCount} servers`;
142
142
  }
@@ -217,7 +217,7 @@ function renderPadding(width: number, variant: FooterVariant): string {
217
217
  }
218
218
 
219
219
  function buildFooterItems(
220
- ctx: ExtensionContext,
220
+ ctx: FooterContext,
221
221
  footerData: FooterData,
222
222
  thinkingLevel: string,
223
223
  ): Partial<Record<FooterKey, FooterItem>> {
@@ -278,7 +278,7 @@ function buildFooterItems(
278
278
  }
279
279
 
280
280
  export function createFooterComponent(
281
- ctx: ExtensionContext,
281
+ ctx: FooterContext,
282
282
  footerData: FooterData,
283
283
  getThinkingLevel: () => string,
284
284
  requestRender: () => void,
@@ -0,0 +1,189 @@
1
+ import {
2
+ InteractiveMode,
3
+ type ExtensionContext,
4
+ type SessionShutdownEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { markFooterComponent, getFooterComponentKind } from "./footer-component.ts";
8
+ import { createFooterComponent } from "./footer-rendering.ts";
9
+ import type { ContextUsage, FooterContext, FooterData, FooterModel } from "./types.ts";
10
+
11
+ const RESET_PATCH_MARKER = Symbol.for("zigai.pi-footer.reset-extension-ui-patch");
12
+ const TRANSITION_STATE_KEY = "__zigaiPiFooterTransitionState__";
13
+ const BRIDGE_FOOTER_TTL_MS = 15_000;
14
+
15
+ type FooterComponent = ReturnType<typeof createFooterComponent>;
16
+ type FooterFactory = (
17
+ tui: { requestRender(): void },
18
+ theme: unknown,
19
+ footerData: FooterData,
20
+ ) => FooterComponent;
21
+
22
+ type FooterResetHost = {
23
+ customFooter?: unknown;
24
+ resetExtensionUI(): void;
25
+ setExtensionFooter(factory: FooterFactory | undefined): void;
26
+ };
27
+
28
+ type PatchableInteractiveModePrototype = {
29
+ customFooter?: unknown;
30
+ resetExtensionUI?: (this: FooterResetHost) => void;
31
+ setExtensionFooter?: (this: FooterResetHost, factory: FooterFactory | undefined) => void;
32
+ [RESET_PATCH_MARKER]?: true;
33
+ };
34
+
35
+ type FooterSnapshot = {
36
+ context: FooterContext;
37
+ thinkingLevel: string;
38
+ };
39
+
40
+ type MaybeMcpContext = ExtensionContext & {
41
+ mcpServers?: unknown[];
42
+ };
43
+
44
+ type FooterTransitionState = {
45
+ latestSnapshot?: FooterSnapshot;
46
+ pendingShutdownReason?: SessionShutdownEvent["reason"];
47
+ liveInstallGeneration: number;
48
+ };
49
+
50
+ type FooterTransitionGlobal = typeof globalThis & {
51
+ [TRANSITION_STATE_KEY]?: FooterTransitionState;
52
+ };
53
+
54
+ function getTransitionState(): FooterTransitionState {
55
+ const globalState = globalThis as FooterTransitionGlobal;
56
+ let state = globalState[TRANSITION_STATE_KEY];
57
+ if (state === undefined) {
58
+ state = { liveInstallGeneration: 0 };
59
+ globalState[TRANSITION_STATE_KEY] = state;
60
+ }
61
+ return state;
62
+ }
63
+
64
+ function cloneContextUsage(usage: ContextUsage): ContextUsage {
65
+ if (usage === undefined) return undefined;
66
+ return { ...usage };
67
+ }
68
+
69
+ function cloneModel(model: ExtensionContext["model"]): FooterModel | undefined {
70
+ if (model === undefined) return undefined;
71
+
72
+ return {
73
+ provider: model.provider,
74
+ id: model.id,
75
+ contextWindow: model.contextWindow,
76
+ };
77
+ }
78
+
79
+ function cloneMcpServers(ctx: ExtensionContext): unknown[] | undefined {
80
+ const servers = (ctx as MaybeMcpContext).mcpServers;
81
+ if (!Array.isArray(servers)) return undefined;
82
+
83
+ return Array.from<unknown>({ length: servers.length });
84
+ }
85
+
86
+ function createFooterSnapshot(ctx: ExtensionContext, thinkingLevel: string): FooterSnapshot {
87
+ const usage = cloneContextUsage(ctx.getContextUsage());
88
+
89
+ return {
90
+ context: {
91
+ cwd: ctx.cwd,
92
+ model: cloneModel(ctx.model),
93
+ mcpServers: cloneMcpServers(ctx),
94
+ getContextUsage() {
95
+ return usage;
96
+ },
97
+ },
98
+ thinkingLevel,
99
+ };
100
+ }
101
+
102
+ function shouldBridgeFooter(kind: string | undefined, state: FooterTransitionState): boolean {
103
+ if (kind !== "live" && kind !== "bridge") return false;
104
+ if (state.latestSnapshot === undefined) return false;
105
+ if (state.pendingShutdownReason === undefined) return false;
106
+ if (state.pendingShutdownReason === "quit") return false;
107
+ return true;
108
+ }
109
+
110
+ function installBridgeFooter(host: FooterResetHost, snapshot: FooterSnapshot): void {
111
+ const generationAtInstall = getTransitionState().liveInstallGeneration;
112
+
113
+ host.setExtensionFooter((tui, _theme, footerData) => {
114
+ const component = createFooterComponent(
115
+ snapshot.context,
116
+ footerData,
117
+ () => snapshot.thinkingLevel,
118
+ () => tui.requestRender(),
119
+ );
120
+ return markFooterComponent(component, "bridge");
121
+ });
122
+
123
+ const timeout = setTimeout(() => {
124
+ const state = getTransitionState();
125
+ if (state.liveInstallGeneration !== generationAtInstall) return;
126
+ if (getFooterComponentKind(host.customFooter) !== "bridge") return;
127
+ host.setExtensionFooter(undefined);
128
+ }, BRIDGE_FOOTER_TTL_MS);
129
+ timeout.unref?.();
130
+ }
131
+
132
+ export function patchFooterReset(): void {
133
+ // Pi resets extension-owned UI between session replacements before the
134
+ // replacement session has emitted session_start. Reinstall a snapshot-backed
135
+ // bridge footer in that same reset call so the built-in footer never paints
136
+ // during the handoff.
137
+ const prototype = InteractiveMode.prototype as unknown as PatchableInteractiveModePrototype;
138
+ if (prototype[RESET_PATCH_MARKER] === true) return;
139
+
140
+ const originalReset = prototype.resetExtensionUI;
141
+ const setExtensionFooter = prototype.setExtensionFooter;
142
+ if (typeof originalReset !== "function") return;
143
+ if (typeof setExtensionFooter !== "function") return;
144
+
145
+ prototype.resetExtensionUI = function patchedFooterReset(this: FooterResetHost): void {
146
+ const footerKind = getFooterComponentKind(this.customFooter);
147
+ const state = getTransitionState();
148
+ const snapshot = state.latestSnapshot;
149
+ const bridgeFooter = shouldBridgeFooter(footerKind, state);
150
+
151
+ originalReset.call(this);
152
+ state.pendingShutdownReason = undefined;
153
+
154
+ if (!bridgeFooter || snapshot === undefined) return;
155
+ installBridgeFooter(this, snapshot);
156
+ };
157
+
158
+ prototype[RESET_PATCH_MARKER] = true;
159
+ }
160
+
161
+ export function installLiveFooter(ctx: ExtensionContext, getThinkingLevel: () => string): void {
162
+ ctx.ui.setFooter((tui, _theme, footerData) => {
163
+ const component = createFooterComponent(ctx, footerData, getThinkingLevel, () =>
164
+ tui.requestRender(),
165
+ );
166
+ return markFooterComponent(component, "live");
167
+ });
168
+
169
+ const state = getTransitionState();
170
+ state.latestSnapshot = createFooterSnapshot(ctx, getThinkingLevel());
171
+ state.pendingShutdownReason = undefined;
172
+ state.liveInstallGeneration += 1;
173
+ }
174
+
175
+ export function rememberFooterForTransition(
176
+ ctx: ExtensionContext,
177
+ reason: SessionShutdownEvent["reason"],
178
+ thinkingLevel: string,
179
+ ): void {
180
+ const state = getTransitionState();
181
+ state.pendingShutdownReason = reason;
182
+
183
+ if (reason === "quit") {
184
+ state.latestSnapshot = undefined;
185
+ return;
186
+ }
187
+
188
+ state.latestSnapshot = createFooterSnapshot(ctx, thinkingLevel);
189
+ }
package/src/index.ts CHANGED
@@ -1,103 +1,25 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { createFooterComponent } from "./footer-rendering.ts";
4
- import { formatDuration, setWorkedForWidget } from "./worked-for-widget.ts";
3
+ import {
4
+ installLiveFooter,
5
+ patchFooterReset,
6
+ rememberFooterForTransition,
7
+ } from "./footer-transition.ts";
5
8
 
6
9
  export default function uiEnhancements(pi: ExtensionAPI) {
7
- let agentStartedAt: number | undefined;
8
- let messageStart: number | undefined;
9
- let streamStart: number | undefined;
10
- let estimatedStreamedTokens = 0;
11
- let totalOutputTokens = 0;
12
- let totalStreamMs = 0;
10
+ patchFooterReset();
11
+
12
+ const getThinkingLevel = () => pi.getThinkingLevel();
13
13
 
14
14
  const installFooter = (ctx: ExtensionContext) => {
15
- ctx.ui.setFooter((tui, _theme, footerData) =>
16
- createFooterComponent(
17
- ctx,
18
- footerData,
19
- () => pi.getThinkingLevel(),
20
- () => tui.requestRender(),
21
- ),
22
- );
15
+ installLiveFooter(ctx, getThinkingLevel);
23
16
  };
24
17
 
25
18
  pi.on("session_start", async (_event, ctx) => {
26
19
  installFooter(ctx);
27
- setWorkedForWidget(ctx, undefined);
28
- });
29
-
30
- pi.on("agent_start", async (_event, ctx) => {
31
- agentStartedAt = Date.now();
32
- messageStart = undefined;
33
- streamStart = undefined;
34
- estimatedStreamedTokens = 0;
35
- totalOutputTokens = 0;
36
- totalStreamMs = 0;
37
- setWorkedForWidget(ctx, undefined);
38
- });
39
-
40
- pi.on("message_start", async (event) => {
41
- if (event.message.role !== "assistant") return;
42
- messageStart = Date.now();
43
- streamStart = undefined;
44
- estimatedStreamedTokens = 0;
45
- });
46
-
47
- pi.on("message_update", async (event) => {
48
- if (event.message.role !== "assistant") return;
49
-
50
- const streamEvent = event.assistantMessageEvent;
51
- const isOutputDelta =
52
- streamEvent.type === "text_delta" ||
53
- streamEvent.type === "thinking_delta" ||
54
- streamEvent.type === "toolcall_delta";
55
- if (!isOutputDelta) return;
56
-
57
- streamStart ??= Date.now();
58
- estimatedStreamedTokens += Math.max(0, streamEvent.delta.length / 4);
59
- });
60
-
61
- pi.on("message_end", async (event) => {
62
- if (event.message.role !== "assistant") return;
63
-
64
- const outputTokens = event.message.usage.output;
65
- const timingStart = streamStart ?? messageStart;
66
- if (timingStart === undefined || outputTokens <= 0) {
67
- messageStart = undefined;
68
- streamStart = undefined;
69
- estimatedStreamedTokens = 0;
70
- return;
71
- }
72
-
73
- totalOutputTokens += outputTokens;
74
- totalStreamMs += Math.max(0, Date.now() - timingStart);
75
-
76
- messageStart = undefined;
77
- streamStart = undefined;
78
- estimatedStreamedTokens = 0;
79
- });
80
-
81
- pi.on("agent_end", async (_event, ctx) => {
82
- if (agentStartedAt === undefined) return;
83
- const duration = Date.now() - agentStartedAt;
84
- const elapsedSeconds = totalStreamMs / 1000;
85
- let tokensPerSecond: number | undefined;
86
- if (totalOutputTokens > 0 && elapsedSeconds > 0) {
87
- tokensPerSecond = Math.round(totalOutputTokens / elapsedSeconds);
88
- }
89
- agentStartedAt = undefined;
90
- setWorkedForWidget(ctx, formatDuration(duration), tokensPerSecond);
91
20
  });
92
21
 
93
- pi.on("session_shutdown", async (_event, ctx) => {
94
- agentStartedAt = undefined;
95
- messageStart = undefined;
96
- streamStart = undefined;
97
- estimatedStreamedTokens = 0;
98
- totalOutputTokens = 0;
99
- totalStreamMs = 0;
100
- ctx.ui.setFooter(undefined);
101
- setWorkedForWidget(ctx, undefined);
22
+ pi.on("session_shutdown", async (event, ctx) => {
23
+ rememberFooterForTransition(ctx, event.reason, getThinkingLevel());
102
24
  });
103
25
  }
package/src/types.ts CHANGED
@@ -32,6 +32,17 @@ export type FooterKey = "path" | "branch" | "provider" | "model" | "thinking" |
32
32
  export type Rgb = [number, number, number];
33
33
  export type SegmentColors = { bg: string; fg: string };
34
34
  export type ContextUsage = ReturnType<ExtensionContext["getContextUsage"]>;
35
+ export type FooterModel = {
36
+ provider: string;
37
+ id: string;
38
+ contextWindow?: number;
39
+ };
40
+ export type FooterContext = {
41
+ cwd: string;
42
+ model?: FooterModel;
43
+ mcpServers?: unknown[];
44
+ getContextUsage(): ContextUsage;
45
+ };
35
46
 
36
47
  export type FooterData = {
37
48
  getGitBranch(): string | null;
@@ -1,43 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
3
-
4
- import { WIDGET_KEY } from "./constants.ts";
5
-
6
- export function formatDuration(ms: number): string {
7
- const wholeSeconds = Math.max(0, Math.round(ms / 1000));
8
- if (wholeSeconds < 60) return `${wholeSeconds}s`;
9
-
10
- const minutes = Math.floor(wholeSeconds / 60);
11
- const remainingSeconds = wholeSeconds % 60;
12
- if (minutes < 60) return `${minutes}m ${remainingSeconds.toString().padStart(2, "0")}s`;
13
-
14
- const hours = Math.floor(minutes / 60);
15
- const remainingMinutes = minutes % 60;
16
- return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`;
17
- }
18
-
19
- export function setWorkedForWidget(
20
- ctx: ExtensionContext,
21
- workedForText?: string,
22
- tokensPerSecond?: number,
23
- ): void {
24
- if (ctx.hasUI !== true) return;
25
-
26
- if (workedForText === undefined || workedForText.length === 0) {
27
- ctx.ui.setWidget(WIDGET_KEY, undefined);
28
- return;
29
- }
30
-
31
- ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
32
- render(width: number): string[] {
33
- if (width <= 0) return [""];
34
- let text = `Worked for ${workedForText}.`;
35
- if (tokensPerSecond !== undefined && tokensPerSecond > 0) {
36
- text = `${text} [${tokensPerSecond} tok/s]`;
37
- }
38
- const truncated = truncateToWidth(text, Math.max(0, width - 1), "");
39
- return [theme.fg("dim", ` ${truncated}`)];
40
- },
41
- invalidate() {},
42
- }));
43
- }