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