pi-observability 1.0.1 → 1.3.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.
@@ -5,615 +5,710 @@
5
5
  * - Session input/output tokens & cost
6
6
  * - Live TPS during streaming (chunk-based estimate)
7
7
  * - Session runtime
8
- * - Current model & git branch
8
+ * - Current model, thinking level, fast mode & git branch
9
9
  * - Git diff stats (added/removed lines)
10
10
  * - Context usage (current/max)
11
11
  *
12
+ * It also prints the legacy TPS summary notification at the end of each
13
+ * agent run, so the standalone TPS extension is no longer needed.
14
+ *
12
15
  * Commands:
13
16
  * /obs - Print full observability dashboard + last 10 sessions
14
17
  * /obs-toggle - Toggle the observability footer on/off
18
+ * /obs-settings - Open status bar settings (presets, segments, zones)
15
19
  */
16
20
 
17
- import { mkdir, readFile, writeFile } from "node:fs/promises";
18
21
  import { homedir } from "node:os";
19
22
  import { join } from "node:path";
20
- import type { AssistantMessage } from "@mariozechner/pi-ai";
23
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
21
24
  import type {
22
- ExtensionAPI,
23
- ExtensionContext,
24
- } from "@mariozechner/pi-coding-agent";
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ Theme as PiTheme,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import {
30
+ Key,
31
+ matchesKey,
32
+ SettingsList,
33
+ truncateToWidth,
34
+ visibleWidth,
35
+ } from "@earendil-works/pi-tui";
36
+
25
37
  import {
26
- Key,
27
- matchesKey,
28
- truncateToWidth,
29
- visibleWidth,
30
- } from "@mariozechner/pi-tui";
38
+ loadSettings,
39
+ saveSettings,
40
+ updateSetting,
41
+ toSettingsListItems,
42
+ type SettingsConfig,
43
+ } from "./lib/settings/index.js";
44
+
45
+ import {
46
+ renderFooter,
47
+ fmtDuration,
48
+ fmtTokens,
49
+ shortenPath,
50
+ type FooterInput,
51
+ } from "./lib/footer-engine/index.js";
52
+
53
+ import { createFileStorage, type Storage } from "./lib/storage/index.js";
31
54
 
32
55
  /* ───── Types ───── */
33
56
 
34
57
  interface TurnRecord {
35
- turnIndex: number;
36
- inputTokens: number;
37
- outputTokens: number;
38
- cost: number;
39
- durationMs: number;
40
- tps: number;
41
- model: string;
58
+ turnIndex: number;
59
+ inputTokens: number;
60
+ outputTokens: number;
61
+ cost: number;
62
+ durationMs: number;
63
+ tps: number;
64
+ model: string;
42
65
  }
43
66
 
44
67
  interface PersistedTurn {
45
- customType: "obs-turn";
46
- data: TurnRecord;
68
+ customType: "obs-turn";
69
+ data: TurnRecord;
47
70
  }
48
71
 
49
72
  interface SessionState {
50
- startTime: number;
51
- turns: TurnRecord[];
52
- currentTurnStartTime: number | null;
53
- currentTurnUpdateCount: number;
54
- isStreaming: boolean;
55
- footerEnabled: boolean;
73
+ startTime: number;
74
+ turns: TurnRecord[];
75
+ currentTurnStartTime: number | null;
76
+ currentTurnUpdateCount: number;
77
+ agentStartTime: number | null;
78
+ isStreaming: boolean;
79
+ footerEnabled: boolean;
80
+ fastModeSupported: boolean;
81
+ fastModeEnabled: boolean;
82
+ serviceTier: string | null;
83
+ showFullPath: boolean;
84
+ settings: SettingsConfig;
56
85
  }
57
86
 
58
87
  interface SessionSummary {
59
- endedAt: number;
60
- runtimeMs: number;
61
- turns: number;
62
- inputTokens: number;
63
- outputTokens: number;
64
- cost: number;
65
- model: string;
66
- cwd: string;
67
- branch: string | null;
88
+ endedAt: number;
89
+ runtimeMs: number;
90
+ turns: number;
91
+ inputTokens: number;
92
+ outputTokens: number;
93
+ cost: number;
94
+ model: string;
95
+ cwd: string;
96
+ branch: string | null;
68
97
  }
69
98
 
70
99
  /* ───── Helpers ───── */
71
100
 
72
- function fmtDuration(ms: number): string {
73
- if (!Number.isFinite(ms) || ms < 0) ms = 0;
74
- const s = Math.floor(ms / 1000);
75
- const h = Math.floor(s / 3600);
76
- const m = Math.floor((s % 3600) / 60);
77
- const sec = s % 60;
78
- if (h > 0)
79
- return `${h}:${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
80
- return `${m}:${sec.toString().padStart(2, "0")}`;
81
- }
82
-
83
- function fmtTokens(n: number): string {
84
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
85
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
86
- return `${n}`;
87
- }
88
-
89
- function shortenPath(p: string): string {
90
- const home = homedir();
91
- if (home && p.startsWith(home)) return p.replace(home, "~");
92
- return p;
93
- }
94
-
95
101
  function scanHistoricalTurns(ctx: ExtensionContext): TurnRecord[] {
96
- const turns: TurnRecord[] = [];
97
- for (const entry of ctx.sessionManager.getBranch()) {
98
- if (entry.type === "custom" && entry.customType === "obs-turn") {
99
- turns.push((entry as unknown as PersistedTurn).data);
100
- }
101
- }
102
- return turns;
102
+ const turns: TurnRecord[] = [];
103
+ for (const entry of ctx.sessionManager.getBranch()) {
104
+ if (entry.type === "custom" && entry.customType === "obs-turn") {
105
+ turns.push((entry as unknown as PersistedTurn).data);
106
+ }
107
+ }
108
+ return turns;
103
109
  }
104
110
 
105
111
  function getSessionStartTime(ctx: ExtensionContext): number {
106
- const entries = ctx.sessionManager.getBranch();
107
- for (const e of entries) {
108
- if (typeof e.timestamp === "number" && Number.isFinite(e.timestamp)) {
109
- return e.timestamp;
110
- }
111
- }
112
- return Date.now();
112
+ const entries = ctx.sessionManager.getBranch();
113
+ for (const e of entries) {
114
+ if (typeof e.timestamp === "number" && Number.isFinite(e.timestamp)) {
115
+ return e.timestamp;
116
+ }
117
+ }
118
+ return Date.now();
113
119
  }
114
120
 
115
- function alignCell(
116
- str: string,
117
- width: number,
118
- align: "left" | "right" = "left",
119
- ): string {
120
- const vis = visibleWidth(str);
121
- if (vis > width) return truncateToWidth(str, width);
122
- const pad = width - vis;
123
- return align === "right" ? " ".repeat(pad) + str : str + " ".repeat(pad);
121
+ function alignCell(str: string, width: number, align: "left" | "right" = "left"): string {
122
+ const vis = visibleWidth(str);
123
+ if (vis > width) return truncateToWidth(str, width);
124
+ const pad = width - vis;
125
+ return align === "right" ? " ".repeat(pad) + str : str + " ".repeat(pad);
124
126
  }
125
127
 
126
- /* ───── History persistence ───── */
128
+ function getStringProp(value: unknown, key: string): string | undefined {
129
+ if (!value || typeof value !== "object") return undefined;
130
+ const prop = (value as Record<string, unknown>)[key];
131
+ return typeof prop === "string" ? prop : undefined;
132
+ }
127
133
 
128
- const HISTORY_DIR = join(homedir(), ".pi", "agent", "observability");
129
- const HISTORY_FILE = join(HISTORY_DIR, "history.jsonl");
134
+ function getServiceTierFromPayload(payload: unknown): string | null {
135
+ const tier = getStringProp(payload, "service_tier") ?? getStringProp(payload, "serviceTier");
136
+ return tier?.trim().toLowerCase() || null;
137
+ }
130
138
 
131
- async function loadHistory(): Promise<SessionSummary[]> {
132
- try {
133
- const text = await readFile(HISTORY_FILE, "utf8");
134
- const lines = text.split("\n").filter((l) => l.trim());
135
- return lines.map((l) => JSON.parse(l));
136
- } catch {
137
- return [];
138
- }
139
+ function isFastServiceTier(serviceTier: string | null): boolean {
140
+ // OpenAI's actual fast/priority tier is `priority`. Older/local shims may
141
+ // still emit `fast`, so keep accepting it for backwards-compatible display.
142
+ return serviceTier === "priority" || serviceTier === "fast";
139
143
  }
140
144
 
141
- async function saveHistory(sessions: SessionSummary[]): Promise<void> {
142
- await mkdir(HISTORY_DIR, { recursive: true });
143
- const text = `${sessions.map((s) => JSON.stringify(s)).join("\n")}\n`;
144
- await writeFile(HISTORY_FILE, text, "utf8");
145
+ function supportsFastMode(ctx: ExtensionContext): boolean {
146
+ const model = ctx.model;
147
+ if (!model) return false;
148
+ if (model.provider !== "openai" && model.provider !== "openai-codex") return false;
149
+ return model.api === "openai-responses" || model.api === "openai-codex-responses";
145
150
  }
146
151
 
147
152
  /* ───── Dashboard formatting ───── */
148
153
 
149
- type Theme = {
150
- fg: (color: string, text: string) => string;
151
- bold: (text: string) => string;
152
- };
154
+ type DashboardTheme = Pick<PiTheme, "fg" | "bold">;
153
155
 
154
156
  function buildDashboard(
155
- state: SessionState,
156
- ctx: ExtensionContext,
157
- branch: string | null,
158
- history: SessionSummary[],
159
- termWidth: number,
160
- theme: Theme,
157
+ state: SessionState,
158
+ ctx: ExtensionContext,
159
+ branch: string | null,
160
+ history: SessionSummary[],
161
+ termWidth: number,
162
+ theme: DashboardTheme,
161
163
  ): string[] {
162
- const runtime = Date.now() - state.startTime;
163
- const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
164
- const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
165
- const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
166
-
167
- const B = (s: string) => theme.fg("border", s);
168
- const lines: string[] = [];
169
-
170
- // ── Summary Card ──
171
- const summaryLines = [
172
- theme.bold("Agent Observability Dashboard"),
173
- `Runtime: ${fmtDuration(runtime)} Dir: ${shortenPath(ctx.cwd)}`,
174
- branch
175
- ? `Branch: ${branch} Model: ${ctx.model?.id ?? "none"}`
176
- : `Model: ${ctx.model?.id ?? "none"}`,
177
- `Tokens: ↑${fmtTokens(totalIn)} ↓${fmtTokens(totalOut)}`,
178
- `Cost: $${totalCost.toFixed(6)}`,
179
- ];
180
- const summaryW = Math.min(
181
- Math.max(...summaryLines.map((c) => visibleWidth(c))) + 4,
182
- termWidth,
183
- );
184
- const inner = summaryW - 4;
185
- const padSummary = (text: string) => {
186
- const safe = truncateToWidth(text, inner);
187
- const vis = visibleWidth(safe);
188
- const pad = Math.max(0, inner - vis);
189
- return B("│ ") + safe + B(`${" ".repeat(pad)} │`);
190
- };
191
-
192
- lines.push(B(`┌${"─".repeat(summaryW - 2)}┐`));
193
- lines.push(padSummary(summaryLines[0]));
194
- lines.push(B(`├${"─".repeat(summaryW - 2)}┤`));
195
- for (let i = 1; i < summaryLines.length; i++) {
196
- lines.push(padSummary(summaryLines[i]));
197
- }
198
- lines.push(B(`└${"─".repeat(summaryW - 2)}┘`));
199
-
200
- // ── Turns Table ──
201
- if (state.turns.length > 0) {
202
- lines.push("");
203
- lines.push(
204
- ` ${theme.bold(theme.fg("accent", `TURNS (${state.turns.length})`))}`,
205
- );
206
-
207
- const headers = ["#", "Input", "Output", "Time", "TPS", "Cost", "Model"];
208
- const rows = state.turns.map((t, i) => [
209
- `${i + 1}`,
210
- `↑${fmtTokens(t.inputTokens)}`,
211
- `↓${fmtTokens(t.outputTokens)}`,
212
- fmtDuration(t.durationMs),
213
- `${t.tps.toFixed(1)}`,
214
- `$${t.cost.toFixed(2)}`,
215
- t.model,
216
- ]);
217
-
218
- const colW = headers.map((h, i) =>
219
- Math.max(visibleWidth(h), ...rows.map((r) => visibleWidth(r[i]))),
220
- );
221
- const tableW = colW.reduce((a, b) => a + b, 0) + 2 * (colW.length - 1) + 2;
222
- if (tableW > termWidth && colW[colW.length - 1]! > 10) {
223
- colW[colW.length - 1] = Math.max(
224
- 10,
225
- colW[colW.length - 1]! - (tableW - termWidth),
226
- );
227
- }
228
-
229
- const pad = " ";
230
- const hdr = ` ${headers.map((h, i) => alignCell(h, colW[i]!)).join(pad)}`;
231
- lines.push(theme.fg("dim", hdr));
232
- lines.push(B(` ${"─".repeat(visibleWidth(hdr) - 2)}`));
233
- for (const row of rows) {
234
- const cells = row.map((c, i) =>
235
- alignCell(c, colW[i]!, i === 0 || i >= 3 ? "left" : "right"),
236
- );
237
- lines.push(` ${cells.join(pad)}`);
238
- }
239
- }
240
-
241
- // ── History Table ──
242
- if (history.length > 0) {
243
- lines.push("");
244
- lines.push(` ${theme.bold(theme.fg("accent", "LAST 10 SESSIONS"))}`);
245
-
246
- const headers = [
247
- "When",
248
- "Duration",
249
- "Turns",
250
- "Input",
251
- "Output",
252
- "Cost",
253
- "Model",
254
- ];
255
- const rows = history
256
- .slice()
257
- .reverse()
258
- .map((h) => {
259
- const date = new Date(h.endedAt).toLocaleDateString("en-US", {
260
- month: "short",
261
- day: "numeric",
262
- hour: "2-digit",
263
- minute: "2-digit",
264
- });
265
- return [
266
- date,
267
- fmtDuration(h.runtimeMs),
268
- `${h.turns}`,
269
- `↑${fmtTokens(h.inputTokens)}`,
270
- `↓${fmtTokens(h.outputTokens)}`,
271
- `$${h.cost.toFixed(2)}`,
272
- h.model,
273
- ];
274
- });
275
-
276
- const colW = headers.map((h, i) =>
277
- Math.max(visibleWidth(h), ...rows.map((r) => visibleWidth(r[i]))),
278
- );
279
- const tableW = colW.reduce((a, b) => a + b, 0) + 2 * (colW.length - 1) + 2;
280
- if (tableW > termWidth && colW[colW.length - 1]! > 10) {
281
- colW[colW.length - 1] = Math.max(
282
- 10,
283
- colW[colW.length - 1]! - (tableW - termWidth),
284
- );
285
- }
286
-
287
- const pad = " ";
288
- const hdr = ` ${headers.map((h, i) => alignCell(h, colW[i]!)).join(pad)}`;
289
- lines.push(theme.fg("dim", hdr));
290
- lines.push(B(` ${"─".repeat(visibleWidth(hdr) - 2)}`));
291
- for (const row of rows) {
292
- const cells = row.map((c, i) =>
293
- alignCell(c, colW[i]!, i === 0 || i >= 2 ? "left" : "right"),
294
- );
295
- lines.push(` ${cells.join(pad)}`);
296
- }
297
- }
298
-
299
- return lines;
164
+ const runtime = Date.now() - state.startTime;
165
+ const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
166
+ const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
167
+ const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
168
+
169
+ const B = (s: string) => theme.fg("border", s);
170
+ const lines: string[] = [];
171
+
172
+ // ── Summary Card ──
173
+ const summaryLines = [
174
+ theme.bold("Agent Observability Dashboard"),
175
+ `Runtime: ${fmtDuration(runtime)} Dir: ${shortenPath(ctx.cwd)}`,
176
+ branch
177
+ ? `Branch: ${branch} Model: ${ctx.model?.id ?? "none"}`
178
+ : `Model: ${ctx.model?.id ?? "none"}`,
179
+ state.serviceTier
180
+ ? `Service tier: ${state.serviceTier}${state.fastModeEnabled ? " (fast)" : ""}`
181
+ : `Fast mode: ${state.fastModeSupported ? "available" : "not available"}`,
182
+ `Tokens: ↑${fmtTokens(totalIn)} ↓${fmtTokens(totalOut)}`,
183
+ `Cost: $${totalCost.toFixed(6)}`,
184
+ ];
185
+ const summaryW = Math.min(Math.max(...summaryLines.map((c) => visibleWidth(c))) + 4, termWidth);
186
+ const inner = summaryW - 4;
187
+ const padSummary = (text: string) => {
188
+ const safe = truncateToWidth(text, inner);
189
+ const vis = visibleWidth(safe);
190
+ const pad = Math.max(0, inner - vis);
191
+ return B("│ ") + safe + B(`${" ".repeat(pad)} │`);
192
+ };
193
+
194
+ lines.push(B(`┌${"─".repeat(summaryW - 2)}┐`));
195
+ lines.push(padSummary(summaryLines[0]));
196
+ lines.push(B(`├${"─".repeat(summaryW - 2)}┤`));
197
+ for (let i = 1; i < summaryLines.length; i++) {
198
+ lines.push(padSummary(summaryLines[i]));
199
+ }
200
+ lines.push(B(`└${"─".repeat(summaryW - 2)}┘`));
201
+
202
+ // ── Turns Table ──
203
+ if (state.turns.length > 0) {
204
+ lines.push("");
205
+ lines.push(` ${theme.bold(theme.fg("accent", `TURNS (${state.turns.length})`))}`);
206
+
207
+ const headers = ["#", "Input", "Output", "Time", "TPS", "Cost", "Model"];
208
+ const rows = state.turns.map((t, i) => [
209
+ `${i + 1}`,
210
+ `↑${fmtTokens(t.inputTokens)}`,
211
+ `↓${fmtTokens(t.outputTokens)}`,
212
+ fmtDuration(t.durationMs),
213
+ `${t.tps.toFixed(1)}`,
214
+ `$${t.cost.toFixed(2)}`,
215
+ t.model,
216
+ ]);
217
+
218
+ const colW = headers.map((h, i) =>
219
+ Math.max(visibleWidth(h), ...rows.map((r) => visibleWidth(r[i]))),
220
+ );
221
+ const tableW = colW.reduce((a, b) => a + b, 0) + 2 * (colW.length - 1) + 2;
222
+ if (tableW > termWidth && colW[colW.length - 1]! > 10) {
223
+ colW[colW.length - 1] = Math.max(10, colW[colW.length - 1]! - (tableW - termWidth));
224
+ }
225
+
226
+ const pad = " ";
227
+ const hdr = ` ${headers.map((h, i) => alignCell(h, colW[i]!)).join(pad)}`;
228
+ lines.push(theme.fg("dim", hdr));
229
+ lines.push(B(` ${"─".repeat(visibleWidth(hdr) - 2)}`));
230
+ for (const row of rows) {
231
+ const cells = row.map((c, i) => alignCell(c, colW[i]!, i === 0 || i >= 3 ? "left" : "right"));
232
+ lines.push(` ${cells.join(pad)}`);
233
+ }
234
+ }
235
+
236
+ // ── History Table ──
237
+ if (history.length > 0) {
238
+ lines.push("");
239
+ lines.push(` ${theme.bold(theme.fg("accent", "LAST 10 SESSIONS"))}`);
240
+
241
+ const headers = ["When", "Duration", "Turns", "Input", "Output", "Cost", "Model"];
242
+ const rows = history
243
+ .slice()
244
+ .reverse()
245
+ .map((h) => {
246
+ const date = new Date(h.endedAt).toLocaleDateString("en-US", {
247
+ month: "short",
248
+ day: "numeric",
249
+ hour: "2-digit",
250
+ minute: "2-digit",
251
+ });
252
+ return [
253
+ date,
254
+ fmtDuration(h.runtimeMs),
255
+ `${h.turns}`,
256
+ `↑${fmtTokens(h.inputTokens)}`,
257
+ `↓${fmtTokens(h.outputTokens)}`,
258
+ `$${h.cost.toFixed(2)}`,
259
+ h.model,
260
+ ];
261
+ });
262
+
263
+ const colW = headers.map((h, i) =>
264
+ Math.max(visibleWidth(h), ...rows.map((r) => visibleWidth(r[i]))),
265
+ );
266
+ const tableW = colW.reduce((a, b) => a + b, 0) + 2 * (colW.length - 1) + 2;
267
+ if (tableW > termWidth && colW[colW.length - 1]! > 10) {
268
+ colW[colW.length - 1] = Math.max(10, colW[colW.length - 1]! - (tableW - termWidth));
269
+ }
270
+
271
+ const pad = " ";
272
+ const hdr = ` ${headers.map((h, i) => alignCell(h, colW[i]!)).join(pad)}`;
273
+ lines.push(theme.fg("dim", hdr));
274
+ lines.push(B(` ${"─".repeat(visibleWidth(hdr) - 2)}`));
275
+ for (const row of rows) {
276
+ const cells = row.map((c, i) => alignCell(c, colW[i]!, i === 0 || i >= 2 ? "left" : "right"));
277
+ lines.push(` ${cells.join(pad)}`);
278
+ }
279
+ }
280
+
281
+ return lines;
300
282
  }
301
283
 
302
284
  /* ───── Extension ───── */
303
285
 
304
286
  export default function (pi: ExtensionAPI) {
305
- const state: SessionState = {
306
- startTime: Date.now(),
307
- turns: [],
308
- currentTurnStartTime: null,
309
- currentTurnUpdateCount: 0,
310
- isStreaming: false,
311
- footerEnabled: true,
312
- };
313
-
314
- /* ─── Lifecycle ─── */
315
-
316
- pi.on("session_start", async (_event, ctx) => {
317
- state.startTime = getSessionStartTime(ctx);
318
- state.turns = scanHistoricalTurns(ctx);
319
- state.currentTurnStartTime = null;
320
- state.currentTurnUpdateCount = 0;
321
- state.isStreaming = false;
322
-
323
- if (state.footerEnabled && ctx.hasUI) {
324
- setupFooter(ctx);
325
- }
326
- });
327
-
328
- pi.on("turn_start", async (_event, _ctx) => {
329
- state.currentTurnStartTime = Date.now();
330
- state.currentTurnUpdateCount = 0;
331
- state.isStreaming = true;
332
- });
333
-
334
- pi.on("message_update", async (_event, _ctx) => {
335
- state.currentTurnUpdateCount++;
336
- });
337
-
338
- pi.on("turn_end", async (event, ctx) => {
339
- const duration = state.currentTurnStartTime
340
- ? Date.now() - state.currentTurnStartTime
341
- : 0;
342
-
343
- let inputTokens = 0;
344
- let outputTokens = 0;
345
- let cost = 0;
346
-
347
- const branch = ctx.sessionManager.getBranch();
348
- for (let i = branch.length - 1; i >= 0; i--) {
349
- const entry = branch[i];
350
- if (entry.type === "message" && entry.message.role === "assistant") {
351
- const m = entry.message as AssistantMessage;
352
- inputTokens = m.usage?.input ?? 0;
353
- outputTokens = m.usage?.output ?? 0;
354
- cost = m.usage?.cost?.total ?? 0;
355
- break;
356
- }
357
- }
358
-
359
- const tps =
360
- duration > 0 && outputTokens >= 0 ? outputTokens / (duration / 1000) : 0;
361
-
362
- const record: TurnRecord = {
363
- turnIndex: event.turnIndex,
364
- inputTokens,
365
- outputTokens,
366
- cost,
367
- durationMs: duration,
368
- tps,
369
- model: ctx.model?.id ?? "unknown",
370
- };
371
-
372
- state.turns.push(record);
373
- state.isStreaming = false;
374
- state.currentTurnStartTime = null;
375
- state.currentTurnUpdateCount = 0;
376
-
377
- pi.appendEntry("obs-turn", record);
378
- });
379
-
380
- pi.on("agent_end", async (_event, _ctx) => {
381
- state.isStreaming = false;
382
- });
383
-
384
- pi.on("session_shutdown", async (_event, ctx) => {
385
- const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
386
- const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
387
- const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
388
- const runtime = Date.now() - state.startTime;
389
-
390
- let branch: string | null = null;
391
- try {
392
- const result = await pi.exec("git", ["branch", "--show-current"], {
393
- cwd: ctx.cwd,
394
- });
395
- branch = result.stdout?.trim() || null;
396
- } catch {
397
- branch = null;
398
- }
399
-
400
- const summary: SessionSummary = {
401
- endedAt: Date.now(),
402
- runtimeMs: runtime,
403
- turns: state.turns.length,
404
- inputTokens: totalIn,
405
- outputTokens: totalOut,
406
- cost: totalCost,
407
- model: ctx.model?.id ?? "unknown",
408
- cwd: ctx.cwd,
409
- branch,
410
- };
411
-
412
- const history = await loadHistory();
413
- history.push(summary);
414
- if (history.length > 10) history.splice(0, history.length - 10);
415
- await saveHistory(history);
416
- });
417
-
418
- /* ─── Footer ─── */
419
-
420
- function setupFooter(ctx: ExtensionContext) {
421
- ctx.ui.setFooter((tui, theme, footerData) => {
422
- let diffAdded = 0;
423
- let diffRemoved = 0;
424
-
425
- async function refreshDiff() {
426
- try {
427
- const result = await pi.exec("git", ["diff", "--numstat"], {
428
- cwd: ctx.cwd,
429
- });
430
- if (result.code !== 0 || !result.stdout) {
431
- diffAdded = 0;
432
- diffRemoved = 0;
433
- return;
434
- }
435
- let added = 0;
436
- let removed = 0;
437
- for (const line of result.stdout.split("\n")) {
438
- const parts = line.trim().split(/\s+/);
439
- if (parts.length >= 2) {
440
- const a = parseInt(parts[0], 10);
441
- const b = parseInt(parts[1], 10);
442
- if (!Number.isNaN(a)) added += a;
443
- if (!Number.isNaN(b)) removed += b;
444
- }
445
- }
446
- diffAdded = added;
447
- diffRemoved = removed;
448
- } catch {
449
- diffAdded = 0;
450
- diffRemoved = 0;
451
- }
452
- }
453
-
454
- refreshDiff();
455
-
456
- const unsubBranch = footerData.onBranchChange(() => {
457
- refreshDiff();
458
- tui.requestRender();
459
- });
460
-
461
- const timer = setInterval(() => {
462
- refreshDiff();
463
- tui.requestRender();
464
- }, 1000);
465
-
466
- return {
467
- dispose() {
468
- unsubBranch();
469
- clearInterval(timer);
470
- },
471
- invalidate() {},
472
- render(width: number): string[] {
473
- const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
474
- const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
475
- const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
476
- let runtime = Date.now() - state.startTime;
477
- if (!Number.isFinite(runtime) || runtime < 0) runtime = 0;
478
-
479
- const branch = footerData.getGitBranch();
480
- const model = ctx.model?.id ?? "no-model";
481
- const cwd = shortenPath(ctx.cwd);
482
-
483
- // ── Line 1: folder + branch + git diff stats ──
484
- const branchPart = branch ? ` (${branch})` : "";
485
- const diffPart =
486
- diffAdded > 0 || diffRemoved > 0
487
- ? ` ${theme.fg("success", `+${diffAdded}`)} ${theme.fg("error", `-${diffRemoved}`)}`
488
- : "";
489
- const line1Raw = theme.fg("dim", `${cwd}${branchPart}`) + diffPart;
490
- const line1 = truncateToWidth(line1Raw, width);
491
-
492
- // ── Line 2: runtime, context, tokens, cost, TPS, model ──
493
- const segRuntime = theme.fg("dim", `⏱ ${fmtDuration(runtime)}`);
494
-
495
- const ctxUsage = ctx.getContextUsage();
496
- const segCtx = ctxUsage
497
- ? theme.fg(
498
- "dim",
499
- `ctx ${fmtTokens(ctxUsage.tokens || 0)}/${fmtTokens(ctxUsage.contextWindow)}`,
500
- )
501
- : "";
502
-
503
- const segTokens = theme.fg(
504
- "dim",
505
- `↑${fmtTokens(totalIn)} ↓${fmtTokens(totalOut)}`,
506
- );
507
- const segCost = theme.fg("dim", `$${totalCost.toFixed(4)}`);
508
-
509
- let segTps = "";
510
- if (state.isStreaming && state.currentTurnStartTime) {
511
- const elapsed = (Date.now() - state.currentTurnStartTime) / 1000;
512
- const liveTps =
513
- elapsed > 0 ? state.currentTurnUpdateCount / elapsed : 0;
514
- segTps = theme.fg("accent", `⚡ ${liveTps.toFixed(1)} tok/s`);
515
- } else if (state.turns.length > 0) {
516
- const last = state.turns[state.turns.length - 1];
517
- segTps = theme.fg("accent", `⚡ ${last.tps.toFixed(1)} tok/s`);
518
- }
519
-
520
- const segModel = theme.fg("dim", model);
521
-
522
- const leftRaw = [segRuntime, segCtx, segTokens, segCost, segTps]
523
- .filter(Boolean)
524
- .join(" ");
525
- const leftW = visibleWidth(leftRaw);
526
- const rightW = visibleWidth(segModel);
527
-
528
- const gap = width - leftW - rightW;
529
- let line2: string;
530
- if (gap >= 1) {
531
- line2 = leftRaw + " ".repeat(gap) + segModel;
532
- } else {
533
- line2 = `${leftRaw} ${segModel}`;
534
- }
535
- line2 = truncateToWidth(line2, width);
536
-
537
- return [line1, line2];
538
- },
539
- };
540
- });
541
- }
542
-
543
- function teardownFooter(ctx: ExtensionContext) {
544
- ctx.ui.setFooter(undefined);
545
- }
546
-
547
- /* ─── Commands ─── */
548
-
549
- pi.registerCommand("obs", {
550
- description:
551
- "Show observability dashboard (tokens, cost, TPS, runtime, history)",
552
- handler: async (_args, ctx) => {
553
- const branchResult = await pi.exec("git", ["branch", "--show-current"], {
554
- cwd: ctx.cwd,
555
- });
556
- const branch = branchResult.stdout?.trim() || null;
557
- const history = await loadHistory();
558
-
559
- await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
560
- let cachedWidth = 0;
561
- let cachedLines: string[] = [];
562
-
563
- return {
564
- invalidate() {
565
- cachedWidth = 0;
566
- cachedLines = [];
567
- },
568
- handleInput(data: string) {
569
- if (
570
- matchesKey(data, Key.escape) ||
571
- matchesKey(data, Key.enter) ||
572
- matchesKey(data, Key.space)
573
- ) {
574
- done();
575
- }
576
- },
577
- render(width: number): string[] {
578
- if (cachedWidth === width && cachedLines.length > 0) {
579
- return cachedLines;
580
- }
581
-
582
- cachedLines = buildDashboard(
583
- state,
584
- ctx,
585
- branch,
586
- history,
587
- width,
588
- theme,
589
- );
590
-
591
- // Add hint at bottom
592
- const hint = theme.fg("dim", "Press ESC or Enter to close");
593
- const hintVisible = visibleWidth(hint);
594
- const pad = Math.max(0, width - hintVisible);
595
- cachedLines.push("");
596
- cachedLines.push(hint + " ".repeat(pad));
597
-
598
- cachedWidth = width;
599
- return cachedLines;
600
- },
601
- };
602
- });
603
- },
604
- });
605
-
606
- pi.registerCommand("obs-toggle", {
607
- description: "Toggle the observability footer on/off",
608
- handler: async (_args, ctx) => {
609
- state.footerEnabled = !state.footerEnabled;
610
- if (state.footerEnabled) {
611
- setupFooter(ctx);
612
- ctx.ui.notify("Observability footer enabled", "success");
613
- } else {
614
- teardownFooter(ctx);
615
- ctx.ui.notify("Observability footer disabled", "info");
616
- }
617
- },
618
- });
287
+ const storage: Storage = createFileStorage({
288
+ dir: join(homedir(), ".pi", "agent", "observability"),
289
+ });
290
+
291
+ const state: SessionState = {
292
+ startTime: Date.now(),
293
+ turns: [],
294
+ currentTurnStartTime: null,
295
+ currentTurnUpdateCount: 0,
296
+ agentStartTime: null,
297
+ isStreaming: false,
298
+ footerEnabled: true,
299
+ fastModeSupported: false,
300
+ fastModeEnabled: false,
301
+ serviceTier: null,
302
+ showFullPath: false,
303
+ settings: {
304
+ version: 1,
305
+ preset: "standard",
306
+ segments: {
307
+ modelThink: true,
308
+ runtime: true,
309
+ pwd: true,
310
+ git: true,
311
+ contextUsage: true,
312
+ contextProgress: true,
313
+ contextPercentage: true,
314
+ contextNumbers: true,
315
+ tokens: true,
316
+ tps: true,
317
+ cost: true,
318
+ },
319
+ contextZones: { expert: 70, warning: 85 },
320
+ },
321
+ };
322
+
323
+ /* ─── Lifecycle ─── */
324
+
325
+ pi.on("session_start", async (_event, ctx) => {
326
+ state.startTime = getSessionStartTime(ctx);
327
+ state.turns = scanHistoricalTurns(ctx);
328
+ state.currentTurnStartTime = null;
329
+ state.currentTurnUpdateCount = 0;
330
+ state.agentStartTime = null;
331
+ state.isStreaming = false;
332
+ state.fastModeSupported = supportsFastMode(ctx);
333
+ state.fastModeEnabled = false;
334
+ state.serviceTier = null;
335
+ state.settings = await loadSettings(storage);
336
+
337
+ if (state.footerEnabled && ctx.hasUI) {
338
+ setupFooter(ctx);
339
+ }
340
+ });
341
+
342
+ pi.on("agent_start", async () => {
343
+ state.agentStartTime = Date.now();
344
+ });
345
+
346
+ pi.on("turn_start", async (_event, _ctx) => {
347
+ state.currentTurnStartTime = Date.now();
348
+ state.currentTurnUpdateCount = 0;
349
+ state.isStreaming = true;
350
+ });
351
+
352
+ pi.on("model_select", async (_event, ctx) => {
353
+ state.fastModeSupported = supportsFastMode(ctx);
354
+ state.fastModeEnabled = false;
355
+ state.serviceTier = null;
356
+ });
357
+
358
+ pi.on("before_provider_request", async (event, ctx) => {
359
+ state.serviceTier = getServiceTierFromPayload(event.payload);
360
+ state.fastModeEnabled = isFastServiceTier(state.serviceTier);
361
+ state.fastModeSupported = supportsFastMode(ctx) || state.fastModeEnabled;
362
+ });
363
+
364
+ pi.on("message_update", async (_event, _ctx) => {
365
+ state.currentTurnUpdateCount++;
366
+ });
367
+
368
+ pi.on("turn_end", async (event, ctx) => {
369
+ const duration = state.currentTurnStartTime ? Date.now() - state.currentTurnStartTime : 0;
370
+
371
+ let inputTokens = 0;
372
+ let outputTokens = 0;
373
+ let cost = 0;
374
+
375
+ const branch = ctx.sessionManager.getBranch();
376
+ for (let i = branch.length - 1; i >= 0; i--) {
377
+ const entry = branch[i];
378
+ if (entry.type === "message" && entry.message.role === "assistant") {
379
+ const m = entry.message as AssistantMessage;
380
+ inputTokens = m.usage?.input ?? 0;
381
+ outputTokens = m.usage?.output ?? 0;
382
+ cost = m.usage?.cost?.total ?? 0;
383
+ break;
384
+ }
385
+ }
386
+
387
+ const tps = duration > 0 && outputTokens >= 0 ? outputTokens / (duration / 1000) : 0;
388
+
389
+ const record: TurnRecord = {
390
+ turnIndex: event.turnIndex,
391
+ inputTokens,
392
+ outputTokens,
393
+ cost,
394
+ durationMs: duration,
395
+ tps,
396
+ model: ctx.model?.id ?? "unknown",
397
+ };
398
+
399
+ state.turns.push(record);
400
+ state.isStreaming = false;
401
+ state.currentTurnStartTime = null;
402
+ state.currentTurnUpdateCount = 0;
403
+
404
+ pi.appendEntry("obs-turn", record);
405
+ });
406
+
407
+ pi.on("agent_end", async (event, ctx) => {
408
+ state.isStreaming = false;
409
+
410
+ if (!ctx.hasUI || state.agentStartTime === null) {
411
+ state.agentStartTime = null;
412
+ return;
413
+ }
414
+
415
+ const elapsedMs = Date.now() - state.agentStartTime;
416
+ state.agentStartTime = null;
417
+ if (elapsedMs <= 0) return;
418
+
419
+ let input = 0;
420
+ let output = 0;
421
+ let cacheRead = 0;
422
+ let cacheWrite = 0;
423
+ let totalTokens = 0;
424
+
425
+ for (const message of event.messages) {
426
+ if (message.role !== "assistant") continue;
427
+ input += message.usage?.input ?? 0;
428
+ output += message.usage?.output ?? 0;
429
+ cacheRead += message.usage?.cacheRead ?? 0;
430
+ cacheWrite += message.usage?.cacheWrite ?? 0;
431
+ totalTokens += message.usage?.totalTokens ?? 0;
432
+ }
433
+
434
+ if (output <= 0) return;
435
+
436
+ const elapsedSeconds = elapsedMs / 1000;
437
+ const tokensPerSecond = output / elapsedSeconds;
438
+ ctx.ui.notify(
439
+ `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`,
440
+ "info",
441
+ );
442
+ });
443
+
444
+ pi.on("session_shutdown", async (_event, ctx) => {
445
+ const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
446
+ const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
447
+ const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
448
+ const runtime = Date.now() - state.startTime;
449
+
450
+ let branch: string | null = null;
451
+ try {
452
+ const result = await pi.exec("git", ["branch", "--show-current"], {
453
+ cwd: ctx.cwd,
454
+ });
455
+ branch = result.stdout?.trim() || null;
456
+ } catch {
457
+ branch = null;
458
+ }
459
+
460
+ const summary: SessionSummary = {
461
+ endedAt: Date.now(),
462
+ runtimeMs: runtime,
463
+ turns: state.turns.length,
464
+ inputTokens: totalIn,
465
+ outputTokens: totalOut,
466
+ cost: totalCost,
467
+ model: ctx.model?.id ?? "unknown",
468
+ cwd: ctx.cwd,
469
+ branch,
470
+ };
471
+
472
+ const historyStore = storage.jsonl<SessionSummary>("history");
473
+ await historyStore.append(summary);
474
+ await historyStore.trim({ keepLast: 10 });
475
+ });
476
+
477
+ /* ─── Footer ─── */
478
+
479
+ function setupFooter(ctx: ExtensionContext) {
480
+ ctx.ui.setFooter((tui, theme, footerData) => {
481
+ let diffAdded = 0;
482
+ let diffRemoved = 0;
483
+
484
+ async function refreshDiff() {
485
+ try {
486
+ const result = await pi.exec("git", ["diff", "--numstat"], {
487
+ cwd: ctx.cwd,
488
+ });
489
+ if (result.code === 0 && result.stdout) {
490
+ let added = 0;
491
+ let removed = 0;
492
+ for (const line of result.stdout.split("\n")) {
493
+ const parts = line.trim().split(/\s+/);
494
+ if (parts.length >= 2) {
495
+ const a = parseInt(parts[0], 10);
496
+ const b = parseInt(parts[1], 10);
497
+ if (!Number.isNaN(a)) added += a;
498
+ if (!Number.isNaN(b)) removed += b;
499
+ }
500
+ }
501
+ diffAdded = added;
502
+ diffRemoved = removed;
503
+ return;
504
+ }
505
+ } catch {
506
+ /* ignore */
507
+ }
508
+ diffAdded = 0;
509
+ diffRemoved = 0;
510
+ }
511
+
512
+ refreshDiff();
513
+
514
+ const unsubBranch = footerData.onBranchChange(() => {
515
+ refreshDiff();
516
+ tui.requestRender();
517
+ });
518
+
519
+ const timer = setInterval(() => {
520
+ refreshDiff();
521
+ tui.requestRender();
522
+ }, 1000);
523
+
524
+ return {
525
+ dispose() {
526
+ unsubBranch();
527
+ clearInterval(timer);
528
+ },
529
+ invalidate() {},
530
+ render(width: number): string[] {
531
+ let totalIn = 0;
532
+ let totalOut = 0;
533
+ let totalCost = 0;
534
+ for (const t of state.turns) {
535
+ totalIn += t.inputTokens;
536
+ totalOut += t.outputTokens;
537
+ totalCost += t.cost;
538
+ }
539
+
540
+ const lastTurnTps = state.turns.length > 0 ? state.turns[state.turns.length - 1]!.tps : 0;
541
+
542
+ const input: FooterInput = {
543
+ model: ctx.model?.id ?? "no-model",
544
+ thinkingLevel: pi.getThinkingLevel(),
545
+ runtimeMs: Date.now() - state.startTime,
546
+ isStreaming: state.isStreaming,
547
+ currentTurnStartTime: state.currentTurnStartTime,
548
+ currentTurnUpdateCount: state.currentTurnUpdateCount,
549
+ lastTurnTps,
550
+ totalInputTokens: totalIn,
551
+ totalOutputTokens: totalOut,
552
+ totalCost,
553
+ fastModeSupported: state.fastModeSupported,
554
+ fastModeEnabled: state.fastModeEnabled,
555
+ serviceTier: state.serviceTier,
556
+ contextUsage: ctx.getContextUsage() ?? null,
557
+ cwd: ctx.cwd,
558
+ showFullPath: state.showFullPath,
559
+ gitBranch: footerData.getGitBranch(),
560
+ gitDiffAdded: diffAdded,
561
+ gitDiffRemoved: diffRemoved,
562
+ settings: state.settings,
563
+ theme,
564
+ };
565
+
566
+ return renderFooter(input, width);
567
+ },
568
+ };
569
+ });
570
+ }
571
+
572
+ function teardownFooter(ctx: ExtensionContext) {
573
+ ctx.ui.setFooter(undefined);
574
+ }
575
+
576
+ /* ─── Commands ─── */
577
+
578
+ pi.registerCommand("obs", {
579
+ description: "Show observability dashboard (tokens, cost, TPS, runtime, history)",
580
+ handler: async (_args, ctx) => {
581
+ const branchResult = await pi.exec("git", ["branch", "--show-current"], {
582
+ cwd: ctx.cwd,
583
+ });
584
+ const branch = branchResult.stdout?.trim() || null;
585
+ const history = await storage.jsonl<SessionSummary>("history").read();
586
+
587
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
588
+ let cachedWidth = 0;
589
+ let cachedLines: string[] = [];
590
+
591
+ return {
592
+ invalidate() {
593
+ cachedWidth = 0;
594
+ cachedLines = [];
595
+ },
596
+ handleInput(data: string) {
597
+ if (
598
+ matchesKey(data, Key.escape) ||
599
+ matchesKey(data, Key.enter) ||
600
+ matchesKey(data, Key.space)
601
+ ) {
602
+ done();
603
+ }
604
+ },
605
+ render(width: number): string[] {
606
+ if (cachedWidth === width && cachedLines.length > 0) {
607
+ return cachedLines;
608
+ }
609
+
610
+ cachedLines = buildDashboard(state, ctx, branch, history, width, theme);
611
+
612
+ // Add hint at bottom
613
+ const hint = theme.fg("dim", "Press ESC or Enter to close");
614
+ const hintVisible = visibleWidth(hint);
615
+ const pad = Math.max(0, width - hintVisible);
616
+ cachedLines.push("");
617
+ cachedLines.push(hint + " ".repeat(pad));
618
+
619
+ cachedWidth = width;
620
+ return cachedLines;
621
+ },
622
+ };
623
+ });
624
+ },
625
+ });
626
+
627
+ pi.registerCommand("obs-toggle", {
628
+ description: "Toggle the observability footer on/off",
629
+ handler: async (_args, ctx) => {
630
+ state.footerEnabled = !state.footerEnabled;
631
+ if (state.footerEnabled) {
632
+ setupFooter(ctx);
633
+ ctx.ui.notify("Observability footer enabled", "info");
634
+ } else {
635
+ teardownFooter(ctx);
636
+ ctx.ui.notify("Observability footer disabled", "info");
637
+ }
638
+ },
639
+ });
640
+
641
+ pi.registerCommand("obs-toggle-path", {
642
+ description: "Toggle between folder name and full path in footer",
643
+ handler: async (_args, ctx) => {
644
+ state.showFullPath = !state.showFullPath;
645
+ const mode = state.showFullPath ? "full path" : "folder name";
646
+ ctx.ui.notify(`Footer path: ${mode}`, "info");
647
+ },
648
+ });
649
+
650
+ pi.registerCommand("obs-settings", {
651
+ description: "Open status bar settings (layout presets, segment toggles, context zones)",
652
+ handler: async (_args, ctx) => {
653
+ if (!ctx.hasUI) {
654
+ ctx.ui.notify("Settings UI requires interactive mode", "error");
655
+ return;
656
+ }
657
+
658
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
659
+ let config = state.settings;
660
+
661
+ const settingsListTheme = {
662
+ label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
663
+ value: (text: string, selected: boolean) =>
664
+ selected ? theme.fg("accent", text) : theme.fg("muted", text),
665
+ description: (text: string) => theme.fg("dim", text),
666
+ cursor: theme.fg("accent", "→ "),
667
+ hint: (text: string) => theme.fg("dim", text),
668
+ };
669
+
670
+ let settingsList: InstanceType<typeof SettingsList> | null = null;
671
+
672
+ function rebuildSettingsList() {
673
+ settingsList = new SettingsList(
674
+ toSettingsListItems(config),
675
+ 10,
676
+ settingsListTheme,
677
+ async (id, newValue) => {
678
+ const result = updateSetting(config, id, newValue);
679
+ config = result.config;
680
+ state.settings = config;
681
+
682
+ for (const u of result.derivedUpdates) {
683
+ settingsList?.updateValue(u.id, u.value);
684
+ }
685
+
686
+ await saveSettings(config, storage);
687
+ tui.requestRender();
688
+ },
689
+ done,
690
+ );
691
+ }
692
+
693
+ rebuildSettingsList();
694
+
695
+ return {
696
+ invalidate() {
697
+ settingsList?.invalidate();
698
+ },
699
+ handleInput(data: string) {
700
+ if (matchesKey(data, Key.escape)) {
701
+ done();
702
+ return;
703
+ }
704
+ settingsList?.handleInput(data);
705
+ },
706
+ render(width: number): string[] {
707
+ if (!settingsList) return [];
708
+ return settingsList.render(width);
709
+ },
710
+ };
711
+ });
712
+ },
713
+ });
619
714
  }