@pi-unipi/footer 2.10.2 → 2.12.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.
@@ -87,14 +87,91 @@ function renderToolCountSegment(ctx: FooterSegmentContext): RenderedSegment {
87
87
  return { content: color(ctx, "model", content), visible: true };
88
88
  }
89
89
 
90
+ // ─── Git metadata (dirty / ahead / behind) ──────────────────────────────
91
+ //
92
+ // pi's ReadonlyFooterDataProvider only exposes getGitBranch(). For glance-style
93
+ // adornments we probe git ourselves: fire-and-forget async refresh, cached,
94
+ // at most one probe every GIT_PROBE_MS. Renders read the last known values.
95
+
96
+ const GIT_PROBE_MS = 2000;
97
+
98
+ interface GitMeta {
99
+ cwd: string;
100
+ at: number;
101
+ inFlight: boolean;
102
+ dirty: boolean | null;
103
+ ahead: number | null;
104
+ behind: number | null;
105
+ }
106
+
107
+ const gitMetaCache: GitMeta = {
108
+ cwd: "", at: 0, inFlight: false, dirty: null, ahead: null, behind: null,
109
+ };
110
+
111
+ function probeGitMeta(cwd: string): void {
112
+ if (gitMetaCache.inFlight) return;
113
+ gitMetaCache.inFlight = true;
114
+ void import("node:child_process").then(({ execFile }) => {
115
+ execFile(
116
+ "git",
117
+ ["--no-optional-locks", "status", "--porcelain=v1", "--branch"],
118
+ { cwd, timeout: 1500 },
119
+ (err, stdout) => {
120
+ gitMetaCache.inFlight = false;
121
+ gitMetaCache.at = Date.now();
122
+ if (err || !stdout) {
123
+ gitMetaCache.dirty = null;
124
+ return;
125
+ }
126
+ let dirty = false;
127
+ let ahead = 0;
128
+ let behind = 0;
129
+ for (const line of stdout.split("\n")) {
130
+ if (line.startsWith("##")) {
131
+ const ab = line.match(/\[ahead (\d+)(?:,\s*behind (\d+))?\]|\[behind (\d+)\]/);
132
+ if (ab) {
133
+ ahead = Number(ab[1] ?? 0);
134
+ behind = Number(ab[2] ?? ab[3] ?? 0);
135
+ }
136
+ } else if (line.trim()) {
137
+ dirty = true;
138
+ }
139
+ }
140
+ gitMetaCache.dirty = dirty;
141
+ gitMetaCache.ahead = ahead;
142
+ gitMetaCache.behind = behind;
143
+ },
144
+ );
145
+ });
146
+ }
147
+
148
+ function getGitMeta(cwd: string): { dirty: boolean | null; ahead: number | null; behind: number | null } {
149
+ const now = Date.now();
150
+ if (gitMetaCache.cwd !== cwd || now - gitMetaCache.at > GIT_PROBE_MS) {
151
+ gitMetaCache.cwd = cwd;
152
+ probeGitMeta(cwd);
153
+ }
154
+ return { dirty: gitMetaCache.dirty, ahead: gitMetaCache.ahead, behind: gitMetaCache.behind };
155
+ }
156
+
90
157
  function renderGitSegment(ctx: FooterSegmentContext): RenderedSegment {
91
158
  const footerData = ctx.footerData as any;
92
159
  const branch = footerData?.getGitBranch?.() ?? null;
93
160
  if (!branch) return { content: "", visible: false };
94
161
 
95
- const isDirty = footerData?.getGitDirty?.() ?? false;
162
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
163
+ const cwd = (piCtx?.sessionManager as any)?.getCwd?.() ?? (piCtx as any)?.cwd ?? process.cwd();
164
+ const meta = getGitMeta(String(cwd));
165
+ const isDirty = meta.dirty === true || (meta.dirty === null && (footerData?.getGitDirty?.() ?? false));
96
166
  const semanticColor: SemanticColor = isDirty ? "gitDirty" : "gitClean";
97
- const content = withIcon("git", branch);
167
+
168
+ // Glance-style adornments: * dirty, ↑N ahead, ↓N behind
169
+ let marks = "";
170
+ if (isDirty) marks += "*";
171
+ if ((meta.ahead ?? 0) > 0) marks += `↑${meta.ahead}`;
172
+ if ((meta.behind ?? 0) > 0) marks += `↓${meta.behind}`;
173
+
174
+ const content = withIcon("git", `${branch}${marks}`);
98
175
  return { content: color(ctx, semanticColor, content), visible: true };
99
176
  }
100
177
 
@@ -180,6 +257,18 @@ function renderHostnameSegment(_ctx: FooterSegmentContext): RenderedSegment {
180
257
  return { content, visible: true };
181
258
  }
182
259
 
260
+ function renderUniBrandSegment(ctx: FooterSegmentContext): RenderedSegment {
261
+ return { content: color(ctx, "brand", "UNI"), visible: true };
262
+ }
263
+
264
+ function renderDirectorySegment(ctx: FooterSegmentContext): RenderedSegment {
265
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
266
+ const cwd = (piCtx?.sessionManager as any)?.getCwd?.() ?? (piCtx as any)?.cwd ?? process.cwd();
267
+ const dir = String(cwd).split("/").filter(Boolean).pop() ?? "~";
268
+ const content = withIcon("directory", dir);
269
+ return { content: color(ctx, "directory", content), visible: true };
270
+ }
271
+
183
272
  // ─── TPS tier color function ────────────────────────────────────────────────
184
273
 
185
274
  function getTpsSemanticColor(tps: number): SemanticColor {
@@ -190,32 +279,48 @@ function getTpsSemanticColor(tps: number): SemanticColor {
190
279
  return "tpsSlow";
191
280
  }
192
281
 
282
+ /** Format a TTFT duration for display: "1.2s" or "350ms". */
283
+ function formatTtft(ms: number): string {
284
+ if (ms >= 10000) return `${Math.round(ms / 1000)}s`;
285
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
286
+ return `${ms}ms`;
287
+ }
288
+
193
289
  function renderTpsSegment(ctx: FooterSegmentContext): RenderedSegment {
194
290
  const streaming = tpsTracker.isStreaming();
195
291
  const liveTps = tpsTracker.getLiveTps();
196
292
  const avgTps = tpsTracker.getSessionAvgTps();
293
+ // Harness-style TTFT average; null until a full turn→first-word sample exists.
294
+ const avgTtft = tpsTracker.getAvgTtftMs();
197
295
 
198
296
  // No data yet — hide
199
- if (!tpsTracker.getTotalOutput()) return { content: "", visible: false };
297
+ if (!tpsTracker.getTotalOutput() && avgTtft === null) return { content: "", visible: false };
200
298
 
201
299
  const icon = getIcon("tps");
202
300
 
301
+ const ttftPart = avgTtft !== null ? ` TTFT ${formatTtft(avgTtft)} \u00b7` : "";
302
+
203
303
  if (streaming && liveTps > 0) {
204
- // Active generation: show live rate + avg
304
+ // Active generation: show live rate + avg + ttft
205
305
  const liveDisplay = Math.round(liveTps);
206
306
  const avgDisplay = Math.round(avgTps);
207
307
  const liveText = `\u2191 ${liveDisplay} T/S`;
208
308
  const avgText = `AVG ${avgDisplay}`;
209
309
  const liveColored = applyColor(getTpsSemanticColor(liveTps), liveText, ctx.theme, ctx.colors);
210
- const avgColored = applyColor("tpsIdle", avgText, ctx.theme, ctx.colors);
310
+ const avgColored = applyColor("tpsIdle", `${ttftPart} ${avgText}`.trimStart(), ctx.theme, ctx.colors);
211
311
  const content = icon ? `${icon} ${liveColored} \u00b7 ${avgColored}` : `${liveColored} \u00b7 ${avgColored}`;
212
312
  return { content, visible: true };
213
313
  }
214
314
 
215
- // Idle: show session average
315
+ // Idle: show session average (or just TTFT when nothing else yet)
316
+ if (!tpsTracker.getTotalOutput()) {
317
+ const text = `TTFT ${formatTtft(avgTtft ?? 0)}`;
318
+ const colored = applyColor("tpsIdle", text, ctx.theme, ctx.colors);
319
+ return { content: icon ? `${icon} ${colored}` : colored, visible: true };
320
+ }
216
321
  const avgDisplay = Math.round(avgTps);
217
322
  const avgText = `AVG ${avgDisplay} T/S`;
218
- const avgColored = applyColor("tpsIdle", avgText, ctx.theme, ctx.colors);
323
+ const avgColored = applyColor("tpsIdle", `${ttftPart} ${avgText}`.trimStart(), ctx.theme, ctx.colors);
219
324
  const content = icon ? `${icon} ${avgColored}` : avgColored;
220
325
  return { content, visible: true };
221
326
  }
@@ -297,6 +402,8 @@ export const CORE_SEGMENTS: FooterSegment[] = [
297
402
  { id: "tokens_out", label: "Tokens Out", shortLabel: "TOUT", description: "Output tokens generated", zone: "center", render: renderTokensSegment("out"), defaultShow: false },
298
403
  { id: "session", label: "Session", shortLabel: "SES", description: "Session identifier", zone: "left", render: renderSessionSegment, defaultShow: false },
299
404
  { id: "hostname", label: "Hostname", shortLabel: "HST", description: "Machine hostname", zone: "left", render: renderHostnameSegment, defaultShow: false },
405
+ { id: "uni", label: "Unipi", shortLabel: "UNI", description: "Unipi brand mark", zone: "left", render: renderUniBrandSegment, defaultShow: true },
406
+ { id: "directory", label: "Directory", shortLabel: "DIR", description: "Current directory name", zone: "left", render: renderDirectorySegment, defaultShow: true },
300
407
  { id: "clock", label: "Clock", shortLabel: "CLK", description: "Current wall time (HH:MM:SS)", zone: "right", render: renderClockSegment, defaultShow: true },
301
408
  { id: "duration", label: "Duration", shortLabel: "DUR", description: "Session duration", zone: "right", render: renderDurationSegment, defaultShow: true },
302
409
  { id: "thinking_level", label: "Thinking", shortLabel: "THK", description: "Current model thinking level", zone: "center", render: renderThinkingLevelSegment, defaultShow: false },