@henryqw/pi-footer 0.4.1 → 0.5.1

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
@@ -29,7 +29,7 @@ pi install npm:@henryqw/pi-footer
29
29
  ```text
30
30
  pi-packages · clear-field-f8d2 · PR #123 · approved
31
31
  ↑ 12.4k · ↓ 2.1k · ↺ 84.3% · ⚡ 87.4 t/s · $ 0.127 · ◔ 36.8% gpt-5.6-luna • high
32
- Codex #1 · 50% · 7d 1d 1h 22m
32
+ Codex #1 · 50% · 7d 1d 1h 22m ◷ 12m 34s
33
33
  ● 🐴 ponytail: ⚡ FULL
34
34
  ```
35
35
 
@@ -39,7 +39,9 @@ Second line shows cumulative input tokens, output tokens, latest cache-hit rate,
39
39
 
40
40
  `off` uses the same dim grey as the model name. Active levels use an ANSI-256 gradient: green `minimal`, yellow-green `low`, lime `medium`, yellow `high`, orange `xhigh`, and red `max`. `ultra` renders as a rainbow when the runtime supplies it. Pi 0.84.2 does not yet accept `ultra`, so that footer path remains unreachable until Pi adds it.
41
41
 
42
- Third line is reserved for non-empty statuses from `@henryqw` extensions, currently Codex quota. Fourth line renders statuses from all other extensions, including Ponytail and `pi-rewind`. If only external statuses exist, third line stays blank so external output remains on line four. Statuses are sorted by key; producer text, spacing, colors, links, and glyphs are preserved.
42
+ Third line shows cumulative agent-work time right-aligned beneath the model. It counts each run from `agent_start` through the final idle `agent_settled`, including automatic retries and auto-compaction inside that run, and excludes idle waits between runs. Standalone `/compact` is excluded because it runs outside the agent-run lifecycle and emits no `agent_start`. The cumulative total is persisted in the session via a `pi-footer:agent-work` custom entry after each finalized run and restored on session resume. Non-empty statuses from `@henryqw` extensions, currently Codex quota, share the left side.
43
+
44
+ Fourth line renders statuses from all other extensions, including Ponytail and `pi-rewind`. Statuses are sorted by key; producer text, spacing, colors, links, and glyphs are preserved.
43
45
 
44
46
  ## Clickable checkout
45
47
 
@@ -12,7 +12,12 @@ const THINKING_COLORS = {
12
12
  xhigh: 208,
13
13
  max: 196,
14
14
  } as const;
15
- const HENRY_STATUS_KEYS = new Set(["pi-multi-codex"]);
15
+ const HENRY_STATUS_KEY = "pi-multi-codex";
16
+ const AGENT_TIME_ENTRY = "pi-footer:agent-work";
17
+
18
+ function isValidMilliseconds(value: unknown): value is number {
19
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
20
+ }
16
21
 
17
22
  function formatTokens(count: number): string {
18
23
  if (count < 1_000) return `${count}`;
@@ -20,6 +25,14 @@ function formatTokens(count: number): string {
20
25
  return `${(count / 1_000_000).toFixed(1)}M`;
21
26
  }
22
27
 
28
+ function formatDuration(milliseconds: number): string {
29
+ const totalSeconds = Math.floor(milliseconds / 1_000);
30
+ const hours = Math.floor(totalSeconds / 3_600);
31
+ const minutes = Math.floor(totalSeconds % 3_600 / 60);
32
+ const seconds = totalSeconds % 60;
33
+ return hours ? `${hours}h ${minutes}m ${seconds}s` : minutes ? `${minutes}m ${seconds}s` : `${seconds}s`;
34
+ }
35
+
23
36
  function sanitizeStatus(text: string): string {
24
37
  return text.replace(/[\r\n]+/g, " ").trim();
25
38
  }
@@ -31,6 +44,13 @@ function align(left: string, right: string, width: number, ellipsis: string): st
31
44
  return left + " ".repeat(width - visibleWidth(left) - visibleWidth(clippedRight)) + clippedRight;
32
45
  }
33
46
 
47
+ // Mirrors align but reserves the right side (runtime) and truncates the left status first.
48
+ function alignRightReserved(left: string, right: string, width: number, ellipsis: string): string {
49
+ const available = Math.max(width - visibleWidth(right) - 2, 0);
50
+ const clippedLeft = truncateToWidth(left, available, ellipsis);
51
+ return clippedLeft + " ".repeat(Math.max(width - visibleWidth(clippedLeft) - visibleWidth(right), 0)) + truncateToWidth(right, width, "");
52
+ }
53
+
34
54
  function color(text: string, ansi256: number): string {
35
55
  return `\x1b[38;5;${ansi256}m${text}\x1b[39m`;
36
56
  }
@@ -41,7 +61,50 @@ function rainbow(text: string): string {
41
61
  }
42
62
 
43
63
  export default function footerExtension(pi: ExtensionAPI): void {
64
+ let activeMilliseconds = 0;
65
+ let activeStartedAt: number | undefined;
66
+ let runtimeTimer: ReturnType<typeof setInterval> | undefined;
67
+ let requestRuntimeRender: (() => void) | undefined;
68
+ const stopRuntimeTimer = () => {
69
+ if (runtimeTimer === undefined) return;
70
+ clearInterval(runtimeTimer);
71
+ runtimeTimer = undefined;
72
+ };
73
+
74
+ const startActive = () => {
75
+ if (activeStartedAt !== undefined) return;
76
+ activeStartedAt = performance.now();
77
+ if (requestRuntimeRender) runtimeTimer = setInterval(requestRuntimeRender, 1_000);
78
+ requestRuntimeRender?.();
79
+ };
80
+ const finalizeActive = (): boolean => {
81
+ if (activeStartedAt === undefined) return false;
82
+ activeMilliseconds += performance.now() - activeStartedAt;
83
+ activeStartedAt = undefined;
84
+ stopRuntimeTimer();
85
+ requestRuntimeRender?.();
86
+ return true;
87
+ };
88
+
89
+ pi.on("agent_start", (_event) => {
90
+ startActive();
91
+ });
92
+ pi.on("agent_settled", (_event, ctx) => {
93
+ if (ctx.isIdle() && finalizeActive()) pi.appendEntry(AGENT_TIME_ENTRY, activeMilliseconds);
94
+ });
95
+ pi.on("session_shutdown", () => stopRuntimeTimer());
96
+
44
97
  pi.on("session_start", async (_event, ctx) => {
98
+ stopRuntimeTimer();
99
+ activeStartedAt = undefined;
100
+ requestRuntimeRender = undefined;
101
+ // Latest valid entry wins; stored data is untrusted.
102
+ activeMilliseconds = 0;
103
+ for (const entry of ctx.sessionManager.getEntries()) {
104
+ if (entry.type === "custom" && entry.customType === AGENT_TIME_ENTRY && isValidMilliseconds(entry.data)) {
105
+ activeMilliseconds = entry.data;
106
+ }
107
+ }
45
108
  if (ctx.mode !== "tui") return;
46
109
 
47
110
  const git = await pi.exec(
@@ -100,9 +163,14 @@ export default function footerExtension(pi: ExtensionAPI): void {
100
163
  };
101
164
 
102
165
  ctx.ui.setFooter((tui, theme, data) => {
103
- const unsubscribe = data.onBranchChange(() => tui.requestRender());
166
+ requestRuntimeRender = () => tui.requestRender();
167
+ const unsubscribe = data.onBranchChange(requestRuntimeRender);
104
168
  return {
105
- dispose: unsubscribe,
169
+ dispose() {
170
+ unsubscribe();
171
+ requestRuntimeRender = undefined;
172
+ stopRuntimeTimer();
173
+ },
106
174
  invalidate() { },
107
175
  render(width: number): string[] {
108
176
  const entries = ctx.sessionManager.getEntries();
@@ -123,10 +191,10 @@ export default function footerExtension(pi: ExtensionAPI): void {
123
191
  .map(([key, text]) => [key, sanitizeStatus(text)] as const)
124
192
  .filter(([, text]) => Boolean(text));
125
193
  const henryStatuses = statuses
126
- .filter(([key]) => HENRY_STATUS_KEYS.has(key))
194
+ .filter(([key]) => key === HENRY_STATUS_KEY)
127
195
  .map(([, text]) => text);
128
196
  const externalStatuses = statuses
129
- .filter(([key]) => !HENRY_STATUS_KEYS.has(key))
197
+ .filter(([key]) => key !== HENRY_STATUS_KEY)
130
198
  .map(([, text]) => text);
131
199
  const thinking = String(ctx.thinkingLevel ?? "off");
132
200
  const thinkingColor = THINKING_COLORS[thinking as keyof typeof THINKING_COLORS];
@@ -143,6 +211,8 @@ export default function footerExtension(pi: ExtensionAPI): void {
143
211
  ? rainbow(thinking)
144
212
  : thinkingColor === undefined ? theme.fg("dim", thinking) : color(thinking, thinkingColor);
145
213
  const model = theme.fg("dim", `${ctx.model?.id ?? "no-model"} • `) + thinkingText;
214
+ const elapsed = activeMilliseconds + (activeStartedAt === undefined ? 0 : performance.now() - activeStartedAt);
215
+ const runtime = theme.fg("dim", `◷ ${formatDuration(elapsed)}`);
146
216
  const identity = branch ? theme.fg("dim", `${repo} · `) : "";
147
217
  const checkout = branch ?? repo;
148
218
  const checkoutLink = openUri ? hyperlink(theme.fg("accent", checkout), openUri) : theme.fg("dim", checkout);
@@ -150,8 +220,8 @@ export default function footerExtension(pi: ExtensionAPI): void {
150
220
  const lines = [
151
221
  firstLine,
152
222
  align(usage, model, width, ellipsis),
223
+ alignRightReserved(henryStatuses.join(" "), runtime, width, ellipsis),
153
224
  ];
154
- if (henryStatuses.length || externalStatuses.length) lines.push(henryStatuses.join(" "));
155
225
  if (externalStatuses.length) lines.push(externalStatuses.join(" "));
156
226
  return lines.map((line) => truncateToWidth(line, width, ellipsis));
157
227
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-footer",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Show concise repository, branch, and usage details in the Pi footer.",
5
5
  "keywords": [
6
6
  "pi-package",