pi-observability 1.0.0 → 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,478 +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 type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
18
- import type { AssistantMessage } from "@mariozechner/pi-ai";
19
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
20
21
  import { homedir } from "node:os";
21
- import { mkdir, readFile, writeFile } from "node:fs/promises";
22
22
  import { join } from "node:path";
23
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ Theme as PiTheme,
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
+
39
+ import {
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";
23
48
 
24
49
  /* ───── Types ───── */
25
50
 
26
51
  interface TurnRecord {
27
- turnIndex: number;
28
- inputTokens: number;
29
- outputTokens: number;
30
- cost: number;
31
- durationMs: number;
32
- tps: number;
33
- model: string;
52
+ turnIndex: number;
53
+ inputTokens: number;
54
+ outputTokens: number;
55
+ cost: number;
56
+ durationMs: number;
57
+ tps: number;
58
+ model: string;
34
59
  }
35
60
 
36
61
  interface PersistedTurn {
37
- customType: "obs-turn";
38
- data: TurnRecord;
62
+ customType: "obs-turn";
63
+ data: TurnRecord;
39
64
  }
40
65
 
41
66
  interface SessionState {
42
- startTime: number;
43
- turns: TurnRecord[];
44
- currentTurnStartTime: number | null;
45
- currentTurnUpdateCount: number;
46
- isStreaming: boolean;
47
- 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;
48
79
  }
49
80
 
50
81
  interface SessionSummary {
51
- endedAt: number;
52
- runtimeMs: number;
53
- turns: number;
54
- inputTokens: number;
55
- outputTokens: number;
56
- cost: number;
57
- model: string;
58
- cwd: string;
59
- 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;
60
91
  }
61
92
 
62
93
  /* ───── Helpers ───── */
63
94
 
64
- function fmtDuration(ms: number): string {
65
- if (!Number.isFinite(ms) || ms < 0) ms = 0;
66
- const s = Math.floor(ms / 1000);
67
- const h = Math.floor(s / 3600);
68
- const m = Math.floor((s % 3600) / 60);
69
- const sec = s % 60;
70
- if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
71
- return `${m}:${sec.toString().padStart(2, "0")}`;
72
- }
73
-
74
- function fmtTokens(n: number): string {
75
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
76
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
77
- return `${n}`;
78
- }
79
-
80
- function shortenPath(p: string): string {
81
- const home = homedir();
82
- if (home && p.startsWith(home)) return p.replace(home, "~");
83
- return p;
84
- }
85
-
86
95
  function scanHistoricalTurns(ctx: ExtensionContext): TurnRecord[] {
87
- const turns: TurnRecord[] = [];
88
- for (const entry of ctx.sessionManager.getBranch()) {
89
- if (entry.type === "custom" && entry.customType === "obs-turn") {
90
- turns.push((entry as unknown as PersistedTurn).data);
91
- }
92
- }
93
- 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;
94
103
  }
95
104
 
96
105
  function getSessionStartTime(ctx: ExtensionContext): number {
97
- const entries = ctx.sessionManager.getBranch();
98
- for (const e of entries) {
99
- if (typeof e.timestamp === "number" && Number.isFinite(e.timestamp)) {
100
- return e.timestamp;
101
- }
102
- }
103
- 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();
104
113
  }
105
114
 
106
- /* ───── History persistence ───── */
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);
120
+ }
107
121
 
108
- const HISTORY_DIR = join(homedir(), ".pi", "agent", "observability");
109
- 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
+ }
110
127
 
111
- async function loadHistory(): Promise<SessionSummary[]> {
112
- try {
113
- const text = await readFile(HISTORY_FILE, "utf8");
114
- const lines = text.split("\n").filter((l) => l.trim());
115
- return lines.map((l) => JSON.parse(l));
116
- } catch {
117
- return [];
118
- }
128
+ function getServiceTierFromPayload(payload: unknown): string | null {
129
+ const tier = getStringProp(payload, "service_tier") ?? getStringProp(payload, "serviceTier");
130
+ return tier?.trim() || null;
119
131
  }
120
132
 
121
- async function saveHistory(sessions: SessionSummary[]): Promise<void> {
122
- await mkdir(HISTORY_DIR, { recursive: true });
123
- const text = sessions.map((s) => JSON.stringify(s)).join("\n") + "\n";
124
- 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
+ );
125
142
  }
126
143
 
127
144
  /* ───── Dashboard formatting ───── */
128
145
 
129
- const BOX_W = 64; // total outer width including ║ borders
130
- const IN_W = BOX_W - 4; // inner width: "║ " + content + " ║"
131
-
132
- function boxTop(): string {
133
- return "╔" + "═".repeat(BOX_W - 2) + "╗";
134
- }
135
- function boxMid(): string {
136
- return "╠" + "═".repeat(BOX_W - 2) + "╣";
137
- }
138
- function boxBot(): string {
139
- return "╚" + "═".repeat(BOX_W - 2) + "╝";
140
- }
141
- function boxLine(text: string): string {
142
- const visible = visibleWidth(text);
143
- let pad = IN_W - visible;
144
- if (pad < 0) pad = 0;
145
- return "║ " + text + " ".repeat(pad) + " ║";
146
+ type DashboardTheme = Pick<PiTheme, "fg" | "bold">;
147
+
148
+ function buildDashboard(
149
+ state: SessionState,
150
+ ctx: ExtensionContext,
151
+ branch: string | null,
152
+ history: SessionSummary[],
153
+ termWidth: number,
154
+ theme: DashboardTheme,
155
+ ): string[] {
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;
146
271
  }
147
272
 
148
273
  /* ───── Extension ───── */
149
274
 
150
275
  export default function (pi: ExtensionAPI) {
151
- const state: SessionState = {
152
- startTime: Date.now(),
153
- turns: [],
154
- currentTurnStartTime: null,
155
- currentTurnUpdateCount: 0,
156
- isStreaming: false,
157
- footerEnabled: true,
158
- };
159
-
160
- /* ─── Lifecycle ─── */
161
-
162
- pi.on("session_start", async (_event, ctx) => {
163
- state.startTime = getSessionStartTime(ctx);
164
- state.turns = scanHistoricalTurns(ctx);
165
- state.currentTurnStartTime = null;
166
- state.currentTurnUpdateCount = 0;
167
- state.isStreaming = false;
168
-
169
- if (state.footerEnabled && ctx.hasUI) {
170
- setupFooter(ctx);
171
- }
172
- });
173
-
174
- pi.on("turn_start", async (_event, _ctx) => {
175
- state.currentTurnStartTime = Date.now();
176
- state.currentTurnUpdateCount = 0;
177
- state.isStreaming = true;
178
- });
179
-
180
- pi.on("message_update", async (_event, _ctx) => {
181
- state.currentTurnUpdateCount++;
182
- });
183
-
184
- pi.on("turn_end", async (event, ctx) => {
185
- const duration = state.currentTurnStartTime
186
- ? Date.now() - state.currentTurnStartTime
187
- : 0;
188
-
189
- let inputTokens = 0;
190
- let outputTokens = 0;
191
- let cost = 0;
192
-
193
- const branch = ctx.sessionManager.getBranch();
194
- for (let i = branch.length - 1; i >= 0; i--) {
195
- const entry = branch[i];
196
- if (entry.type === "message" && entry.message.role === "assistant") {
197
- const m = entry.message as AssistantMessage;
198
- inputTokens = m.usage?.input ?? 0;
199
- outputTokens = m.usage?.output ?? 0;
200
- cost = m.usage?.cost?.total ?? 0;
201
- break;
202
- }
203
- }
204
-
205
- const tps = duration > 0 && outputTokens >= 0 ? outputTokens / (duration / 1000) : 0;
206
-
207
- const record: TurnRecord = {
208
- turnIndex: event.turnIndex,
209
- inputTokens,
210
- outputTokens,
211
- cost,
212
- durationMs: duration,
213
- tps,
214
- model: ctx.model?.id ?? "unknown",
215
- };
216
-
217
- state.turns.push(record);
218
- state.isStreaming = false;
219
- state.currentTurnStartTime = null;
220
- state.currentTurnUpdateCount = 0;
221
-
222
- pi.appendEntry("obs-turn", record);
223
- });
224
-
225
- pi.on("agent_end", async (_event, _ctx) => {
226
- state.isStreaming = false;
227
- });
228
-
229
- pi.on("session_shutdown", async (_event, ctx) => {
230
- const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
231
- const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
232
- const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
233
- const runtime = Date.now() - state.startTime;
234
-
235
- let branch: string | null = null;
236
- try {
237
- const result = await pi.exec("git", ["branch", "--show-current"], {
238
- cwd: ctx.cwd,
239
- throwOnError: false,
240
- });
241
- branch = result.stdout?.trim() || null;
242
- } catch {
243
- branch = null;
244
- }
245
-
246
- const summary: SessionSummary = {
247
- endedAt: Date.now(),
248
- runtimeMs: runtime,
249
- turns: state.turns.length,
250
- inputTokens: totalIn,
251
- outputTokens: totalOut,
252
- cost: totalCost,
253
- model: ctx.model?.id ?? "unknown",
254
- cwd: ctx.cwd,
255
- branch,
256
- };
257
-
258
- const history = await loadHistory();
259
- history.push(summary);
260
- if (history.length > 10) history.splice(0, history.length - 10);
261
- await saveHistory(history);
262
- });
263
-
264
- /* ─── Footer ─── */
265
-
266
- function setupFooter(ctx: ExtensionContext) {
267
- ctx.ui.setFooter((tui, theme, footerData) => {
268
- let diffAdded = 0;
269
- let diffRemoved = 0;
270
-
271
- async function refreshDiff() {
272
- try {
273
- const result = await pi.exec("git", ["diff", "--numstat"], {
274
- cwd: ctx.cwd,
275
- throwOnError: false,
276
- });
277
- if (result.code !== 0 || !result.stdout) {
278
- diffAdded = 0;
279
- diffRemoved = 0;
280
- return;
281
- }
282
- let added = 0;
283
- let removed = 0;
284
- for (const line of result.stdout.split("\n")) {
285
- const parts = line.trim().split(/\s+/);
286
- if (parts.length >= 2) {
287
- const a = parseInt(parts[0], 10);
288
- const b = parseInt(parts[1], 10);
289
- if (!Number.isNaN(a)) added += a;
290
- if (!Number.isNaN(b)) removed += b;
291
- }
292
- }
293
- diffAdded = added;
294
- diffRemoved = removed;
295
- } catch {
296
- diffAdded = 0;
297
- diffRemoved = 0;
298
- }
299
- }
300
-
301
- refreshDiff();
302
-
303
- const unsubBranch = footerData.onBranchChange(() => {
304
- refreshDiff();
305
- tui.requestRender();
306
- });
307
-
308
- const timer = setInterval(() => {
309
- refreshDiff();
310
- tui.requestRender();
311
- }, 1000);
312
-
313
- return {
314
- dispose() {
315
- unsubBranch();
316
- clearInterval(timer);
317
- },
318
- invalidate() {},
319
- render(width: number): string[] {
320
- const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
321
- const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
322
- const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
323
- let runtime = Date.now() - state.startTime;
324
- if (!Number.isFinite(runtime) || runtime < 0) runtime = 0;
325
-
326
- const branch = footerData.getGitBranch();
327
- const model = ctx.model?.id ?? "no-model";
328
- const cwd = shortenPath(ctx.cwd);
329
-
330
- // ── Line 1: folder + branch + git diff stats ──
331
- const branchPart = branch ? ` (${branch})` : "";
332
- const diffPart =
333
- diffAdded > 0 || diffRemoved > 0
334
- ? ` ${theme.fg("success", `+${diffAdded}`)} ${theme.fg("error", `-${diffRemoved}`)}`
335
- : "";
336
- const line1Raw = theme.fg("dim", `${cwd}${branchPart}`) + diffPart;
337
- const line1 = truncateToWidth(line1Raw, width);
338
-
339
- // ── Line 2: runtime, context, tokens, cost, TPS, model ──
340
- const segRuntime = theme.fg("dim", `⏱ ${fmtDuration(runtime)}`);
341
-
342
- const ctxUsage = ctx.getContextUsage();
343
- const segCtx = ctxUsage
344
- ? theme.fg("dim", `ctx ${fmtTokens(ctxUsage.tokens)}/${fmtTokens(ctxUsage.contextWindow)}`)
345
- : "";
346
-
347
- const segTokens = theme.fg("dim", `↑${fmtTokens(totalIn)} ↓${fmtTokens(totalOut)}`);
348
- const segCost = theme.fg("dim", `$${totalCost.toFixed(4)}`);
349
-
350
- let segTps = "";
351
- if (state.isStreaming && state.currentTurnStartTime) {
352
- const elapsed = (Date.now() - state.currentTurnStartTime) / 1000;
353
- const liveTps = elapsed > 0 ? state.currentTurnUpdateCount / elapsed : 0;
354
- segTps = theme.fg("accent", `⚡ ${liveTps.toFixed(1)} tok/s`);
355
- } else if (state.turns.length > 0) {
356
- const last = state.turns[state.turns.length - 1];
357
- segTps = theme.fg("accent", `⚡ ${last.tps.toFixed(1)} tok/s`);
358
- }
359
-
360
- const segModel = theme.fg("dim", model);
361
-
362
- const leftRaw = [segRuntime, segCtx, segTokens, segCost, segTps].filter(Boolean).join(" ");
363
- const leftW = visibleWidth(leftRaw);
364
- const rightW = visibleWidth(segModel);
365
-
366
- const gap = width - leftW - rightW;
367
- let line2: string;
368
- if (gap >= 1) {
369
- line2 = leftRaw + " ".repeat(gap) + segModel;
370
- } else {
371
- line2 = leftRaw + " " + segModel;
372
- }
373
- line2 = truncateToWidth(line2, width);
374
-
375
- return [line1, line2];
376
- },
377
- };
378
- });
379
- }
380
-
381
- function teardownFooter(ctx: ExtensionContext) {
382
- ctx.ui.setFooter(undefined);
383
- }
384
-
385
- /* ─── Commands ─── */
386
-
387
- pi.registerCommand("obs", {
388
- description: "Show observability dashboard (tokens, cost, TPS, runtime, history)",
389
- handler: async (_args, ctx) => {
390
- const lines: string[] = [];
391
- const runtime = Date.now() - state.startTime;
392
-
393
- const branchResult = await pi.exec("git", ["branch", "--show-current"], {
394
- cwd: ctx.cwd,
395
- throwOnError: false,
396
- });
397
- const branch = branchResult.stdout?.trim() || null;
398
-
399
- const totalIn = state.turns.reduce((s, t) => s + t.inputTokens, 0);
400
- const totalOut = state.turns.reduce((s, t) => s + t.outputTokens, 0);
401
- const totalCost = state.turns.reduce((s, t) => s + t.cost, 0);
402
-
403
- // ── Current Session ──
404
- lines.push("");
405
- lines.push(boxTop());
406
- lines.push(boxLine("🕵️ Agent Observability Dashboard"));
407
- lines.push(boxMid());
408
- lines.push(boxLine(`Runtime: ${fmtDuration(runtime)}`));
409
- lines.push(boxLine(`Dir: ${shortenPath(ctx.cwd)}`));
410
- if (branch) lines.push(boxLine(`Branch: ${branch}`));
411
- lines.push(boxLine(`Model: ${ctx.model?.id ?? "none"}`));
412
- lines.push(boxMid());
413
- lines.push(boxLine(`Tokens: ↑${fmtTokens(totalIn)} ↓${fmtTokens(totalOut)}`));
414
- lines.push(boxLine(`Cost: $${totalCost.toFixed(6)}`));
415
-
416
- if (state.turns.length > 0) {
417
- lines.push(boxMid());
418
- lines.push(boxLine("Turns:"));
419
- for (let i = 0; i < state.turns.length; i++) {
420
- const t = state.turns[i];
421
- const parts = [
422
- `#${i + 1}`,
423
- `↑${fmtTokens(t.inputTokens)}`,
424
- `↓${fmtTokens(t.outputTokens)}`,
425
- fmtDuration(t.durationMs),
426
- `${t.tps.toFixed(1)}/s`,
427
- `$${t.cost.toFixed(2)}`,
428
- t.model.slice(0, 14),
429
- ];
430
- lines.push(boxLine(parts.join(" ")));
431
- }
432
- }
433
- lines.push(boxBot());
434
-
435
- // ── History ──
436
- const history = await loadHistory();
437
- if (history.length > 0) {
438
- lines.push("");
439
- lines.push(boxTop());
440
- lines.push(boxLine("📜 Last 10 Sessions"));
441
- lines.push(boxMid());
442
- for (const h of history.slice().reverse()) {
443
- const date = new Date(h.endedAt).toLocaleDateString("en-US", {
444
- month: "short",
445
- day: "numeric",
446
- hour: "2-digit",
447
- minute: "2-digit",
448
- });
449
- const parts = [
450
- date,
451
- fmtDuration(h.runtimeMs),
452
- `${h.turns}t`,
453
- `↑${fmtTokens(h.inputTokens)}`,
454
- `↓${fmtTokens(h.outputTokens)}`,
455
- `$${h.cost.toFixed(2)}`,
456
- h.model.slice(0, 10),
457
- ];
458
- lines.push(boxLine(parts.join(" ")));
459
- }
460
- lines.push(boxBot());
461
- }
462
-
463
- lines.push("");
464
- console.log(lines.join("\n"));
465
- ctx.ui.notify("Observability dashboard printed to console", "info");
466
- },
467
- });
468
-
469
- pi.registerCommand("obs-toggle", {
470
- description: "Toggle the observability footer on/off",
471
- handler: async (_args, ctx) => {
472
- state.footerEnabled = !state.footerEnabled;
473
- if (state.footerEnabled) {
474
- setupFooter(ctx);
475
- ctx.ui.notify("Observability footer enabled", "success");
476
- } else {
477
- teardownFooter(ctx);
478
- ctx.ui.notify("Observability footer disabled", "info");
479
- }
480
- },
481
- });
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
+ });
482
700
  }