@zigai/pi-footer 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zigai/pi-footer",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Pi package that replaces Pi's footer with a compact status line.",
5
5
  "keywords": [
6
6
  "footer",
@@ -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,24 +1,27 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { createFooterComponent } from "./footer-rendering.ts";
3
+ import {
4
+ installLiveFooter,
5
+ patchFooterReset,
6
+ rememberFooterForTransition,
7
+ } from "./footer-transition.ts";
8
+ import { patchTuiShrinkRedraw } from "./tui-shrink-redraw.ts";
4
9
 
5
10
  export default function uiEnhancements(pi: ExtensionAPI) {
11
+ patchFooterReset();
12
+ patchTuiShrinkRedraw();
13
+
14
+ const getThinkingLevel = () => pi.getThinkingLevel();
15
+
6
16
  const installFooter = (ctx: ExtensionContext) => {
7
- ctx.ui.setFooter((tui, _theme, footerData) =>
8
- createFooterComponent(
9
- ctx,
10
- footerData,
11
- () => pi.getThinkingLevel(),
12
- () => tui.requestRender(),
13
- ),
14
- );
17
+ installLiveFooter(ctx, getThinkingLevel);
15
18
  };
16
19
 
17
20
  pi.on("session_start", async (_event, ctx) => {
18
21
  installFooter(ctx);
19
22
  });
20
23
 
21
- pi.on("session_shutdown", async (_event, ctx) => {
22
- ctx.ui.setFooter(undefined);
24
+ pi.on("session_shutdown", async (event, ctx) => {
25
+ rememberFooterForTransition(ctx, event.reason, getThinkingLevel());
23
26
  });
24
27
  }
@@ -0,0 +1,95 @@
1
+ import { TUI } from "@earendil-works/pi-tui";
2
+
3
+ const TUI_SHRINK_REDRAW_PATCH_MARKER = Symbol.for("zigai.pi-footer.tui-shrink-redraw-patch");
4
+
5
+ type PatchableTuiInstance = {
6
+ previousLines?: unknown;
7
+ previousViewportTop?: unknown;
8
+ fullRedrawCount?: unknown;
9
+ overlayStack?: unknown;
10
+ requestRender(force?: boolean): void;
11
+ };
12
+
13
+ type PatchableTuiPrototype = {
14
+ doRender?: (this: PatchableTuiInstance) => void;
15
+ requestRender?: (this: PatchableTuiInstance, force?: boolean) => void;
16
+ [TUI_SHRINK_REDRAW_PATCH_MARKER]?: true;
17
+ };
18
+
19
+ function getLineCount(tui: PatchableTuiInstance): number {
20
+ if (!Array.isArray(tui.previousLines)) return 0;
21
+ return tui.previousLines.length;
22
+ }
23
+
24
+ function getNumber(value: unknown): number | undefined {
25
+ if (typeof value !== "number") return undefined;
26
+ if (!Number.isFinite(value)) return undefined;
27
+ return value;
28
+ }
29
+
30
+ function hasOverlayStack(tui: PatchableTuiInstance): boolean {
31
+ if (!Array.isArray(tui.overlayStack)) return false;
32
+ return tui.overlayStack.length > 0;
33
+ }
34
+
35
+ function fullRedrawOccurred(previous: number | undefined, next: number | undefined): boolean {
36
+ if (previous === undefined || next === undefined) return false;
37
+ return next > previous;
38
+ }
39
+
40
+ function shouldForceRedrawAfterShrink(
41
+ tui: PatchableTuiInstance,
42
+ previousLineCount: number,
43
+ nextLineCount: number,
44
+ previousViewportTop: number | undefined,
45
+ previousFullRedrawCount: number | undefined,
46
+ ): boolean {
47
+ if (nextLineCount >= previousLineCount) return false;
48
+ if (previousViewportTop === undefined || previousViewportTop <= 0) return false;
49
+ if (hasOverlayStack(tui)) return false;
50
+
51
+ const nextFullRedrawCount = getNumber(tui.fullRedrawCount);
52
+ if (fullRedrawOccurred(previousFullRedrawCount, nextFullRedrawCount)) return false;
53
+
54
+ return true;
55
+ }
56
+
57
+ export function patchTuiShrinkRedraw(): void {
58
+ // Pi's differential renderer does not re-anchor the viewport when content
59
+ // shrinks while scrolled. That can leave the footer one row above the
60
+ // terminal bottom after a transient loader/widget row disappears. Force a
61
+ // one-shot full redraw after such a shrink so the viewport snaps back to the
62
+ // bottom without requiring extension widgets to render placeholder rows.
63
+ const prototype = TUI.prototype as unknown as PatchableTuiPrototype;
64
+ if (prototype[TUI_SHRINK_REDRAW_PATCH_MARKER] === true) return;
65
+
66
+ const originalDoRender = prototype.doRender;
67
+ const originalRequestRender = prototype.requestRender;
68
+ if (typeof originalDoRender !== "function") return;
69
+ if (typeof originalRequestRender !== "function") return;
70
+
71
+ prototype.doRender = function patchedDoRender(this: PatchableTuiInstance): void {
72
+ const previousLineCount = getLineCount(this);
73
+ const previousViewportTop = getNumber(this.previousViewportTop);
74
+ const previousFullRedrawCount = getNumber(this.fullRedrawCount);
75
+
76
+ originalDoRender.call(this);
77
+
78
+ const nextLineCount = getLineCount(this);
79
+ if (
80
+ !shouldForceRedrawAfterShrink(
81
+ this,
82
+ previousLineCount,
83
+ nextLineCount,
84
+ previousViewportTop,
85
+ previousFullRedrawCount,
86
+ )
87
+ ) {
88
+ return;
89
+ }
90
+
91
+ originalRequestRender.call(this, true);
92
+ };
93
+
94
+ prototype[TUI_SHRINK_REDRAW_PATCH_MARKER] = true;
95
+ }
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;