@agnishc/edb-quit-summary 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/package.json +38 -0
  4. package/src/index.ts +351 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Agnish Chakraborty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # edb-quit-summary
2
+
3
+ A [pi](https://pi.dev) extension that prints a session summary to your terminal when you quit pi.
4
+
5
+ When you hit `/quit` (or Ctrl+C twice), pi exits and you'll see a formatted summary like:
6
+
7
+ ```
8
+ ── Session Summary ──
9
+
10
+ Session: Refactor auth module
11
+ Duration: 23m 45s
12
+ Model: anthropic/claude-sonnet-4-5
13
+
14
+ Messages:
15
+ User: 8
16
+ Assistant: 8
17
+ Tool calls: 24
18
+
19
+ Tools used:
20
+ edit 12×
21
+ bash 6×
22
+ read 4×
23
+ grep 2×
24
+
25
+ Tokens:
26
+ Input: 45.2k ████████████░░░░
27
+ Output: 12.8k ████░░░░░░░░░░░░
28
+ Cache read: 38.1k ███████████░░░░░
29
+ Cache write: 5.3k
30
+ ────────────────────────────────────
31
+ Total: 101.4k
32
+
33
+ Cost: $0.34
34
+
35
+ ─────────────────────────────
36
+ ```
37
+
38
+ ## How It Works
39
+
40
+ Pi uses an alternate screen buffer for its TUI. When pi exits, the terminal restores the original buffer, wiping anything printed during the session. This extension works around that by:
41
+
42
+ 1. Collecting stats on the `session_shutdown` event (when `reason === "quit"`)
43
+ 2. Registering a `process.on('exit')` callback that fires **after** the TUI is torn down
44
+ 3. Writing the formatted summary directly to `process.stdout`
45
+
46
+ ## Installation
47
+
48
+ ### As a pi package (recommended)
49
+
50
+ ```bash
51
+ pi install npm:@agnishc/edb-quit-summary
52
+ ```
53
+
54
+ ### Manual
55
+
56
+ Copy `src/index.ts` to `~/.pi/agent/extensions/quit-summary.ts`.
57
+
58
+ ## Configuration
59
+
60
+ No configuration needed — it just works. The summary only prints on quit, not on `/new`, `/resume`, `/fork`, or `/reload`.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@agnishc/edb-quit-summary",
3
+ "version": "0.12.0",
4
+ "description": "Pi extension: prints a session summary to the terminal when you hit /quit",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "edb"
9
+ ],
10
+ "type": "module",
11
+ "license": "MIT",
12
+ "author": "Agnish Chakraborty",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
16
+ "directory": "packages/edb-quit-summary"
17
+ },
18
+ "homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-quit-summary#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/agnishcc/pi-extention-monorepo/issues"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ]
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*"
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * edb-quit-summary
3
+ *
4
+ * Prints a session summary to the terminal when you quit pi.
5
+ * Uses process.on('exit') to print after the TUI alternate screen buffer
6
+ * is restored, so the summary is visible in the user's terminal.
7
+ *
8
+ * Layout: raccoon ASCII art on the left, stats on the right.
9
+ * Dynamically scales to terminal width — hides art on narrow terminals,
10
+ * shrinks bars to fit available space.
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+
15
+ // ── ANSI helpers ───────────────────────────────────────────────────────────────
16
+
17
+ const RESET = "\x1b[0m";
18
+ const BOLD = "\x1b[1m";
19
+ const DIM = "\x1b[2m";
20
+ const CYAN = "\x1b[36m";
21
+ const GREEN = "\x1b[32m";
22
+ const YELLOW = "\x1b[33m";
23
+ const MAGENTA = "\x1b[35m";
24
+ const BLUE = "\x1b[34m";
25
+ const WHITE = "\x1b[37m";
26
+
27
+ const b = (t: string) => `${BOLD}${t}${RESET}`;
28
+ const d = (t: string) => `${DIM}${t}${RESET}`;
29
+ const c = (t: string) => `${CYAN}${t}${RESET}`;
30
+ const g = (t: string) => `${GREEN}${t}${RESET}`;
31
+ const y = (t: string) => `${YELLOW}${t}${RESET}`;
32
+ const m = (t: string) => `${MAGENTA}${t}${RESET}`;
33
+ const bl = (t: string) => `${BLUE}${t}${RESET}`;
34
+ const w = (t: string) => `${WHITE}${t}${RESET}`;
35
+
36
+ // ── Raccoon ────────────────────────────────────────────────────────────────────
37
+ //
38
+ // 9 lines. Each entry has:
39
+ // text — plain text (used for width measurement)
40
+ // colored — ANSI-coloured version
41
+ // All text fields are exactly 12 visible chars.
42
+
43
+ const RACCOON: { text: string; colored: string }[] = [
44
+ { text: " /\\ /\\ ", colored: ` ${d("/\\")} ${d("/\\")} ` },
45
+ { text: " ( oo ) ", colored: ` ( ${b(" oo ")} ) ` },
46
+ { text: " ( ,-___-, )", colored: ` ( ,${d("_____")}, )` },
47
+ { text: " \\_______/ ", colored: ` ${d("\\______/")} ` },
48
+ { text: " / \\ ", colored: ` / \\ ` },
49
+ { text: " ( | | | | )", colored: ` ( ${d("| | | |")} )` },
50
+ { text: " \\ ===== / ", colored: ` \\ ${d("=====")} / ` },
51
+ { text: " \\ / ", colored: ` \\ / ` },
52
+ { text: " `---' ", colored: ` \`---' ` },
53
+ ];
54
+
55
+ // ── Formatters ─────────────────────────────────────────────────────────────────
56
+
57
+ function formatDuration(ms: number): string {
58
+ const s = Math.floor(ms / 1000);
59
+ if (s < 60) return `${s}s`;
60
+ const mn = Math.floor(s / 60),
61
+ sec = s % 60;
62
+ if (mn < 60) return sec > 0 ? `${mn}m ${sec}s` : `${mn}m`;
63
+ const h = Math.floor(mn / 60),
64
+ min = mn % 60;
65
+ return min > 0 ? `${h}h ${min}m` : `${h}h`;
66
+ }
67
+
68
+ function formatCost(cost: number): string {
69
+ if (cost < 0.001) return "$0.00";
70
+ if (cost < 0.01) return `$${cost.toFixed(3)}`;
71
+ return `$${cost.toFixed(2)}`;
72
+ }
73
+
74
+ function formatTokens(n: number): string {
75
+ if (n < 1_000) return `${n}`;
76
+ if (n < 1_000_000) return `${(n / 1_000).toFixed(1)}k`;
77
+ return `${(n / 1_000_000).toFixed(2)}M`;
78
+ }
79
+
80
+ /** Strip ANSI escape codes; return visible character count. */
81
+ function visLen(s: string): number {
82
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
83
+ }
84
+
85
+ /** Pad/truncate a string (which may contain ANSI) to an exact visible width. */
86
+ function padTo(s: string, width: number): string {
87
+ const vis = visLen(s);
88
+ if (vis >= width) return s;
89
+ return s + " ".repeat(width - vis);
90
+ }
91
+
92
+ /** Truncate a plain string to a max length, appending "…" if truncated. */
93
+ function truncate(s: string, max: number): string {
94
+ if (s.length <= max) return s;
95
+ return s.slice(0, max - 1) + "…";
96
+ }
97
+
98
+ // ── Stats collection ───────────────────────────────────────────────────────────
99
+
100
+ interface SessionStats {
101
+ sessionName: string | undefined;
102
+ sessionId: string | undefined;
103
+ startTime: number;
104
+ endTime: number;
105
+ userMessages: number;
106
+ assistantMessages: number;
107
+ toolCalls: number;
108
+ toolCounts: Map<string, number>;
109
+ inputTokens: number;
110
+ outputTokens: number;
111
+ cacheRead: number;
112
+ cacheWrite: number;
113
+ totalCost: number;
114
+ model: string;
115
+ provider: string;
116
+ }
117
+
118
+ function collectStats(ctx: any): SessionStats {
119
+ const entries: any[] = ctx.sessionManager.getEntries();
120
+ const sessionName: string | undefined = ctx.sessionManager.getSessionName?.();
121
+ const sessionId: string | undefined = ctx.sessionManager.getSessionId?.();
122
+
123
+ let startTime = 0;
124
+ let endTime = 0;
125
+ let userMessages = 0;
126
+ let assistantMessages = 0;
127
+ let toolCalls = 0;
128
+ const toolCounts = new Map<string, number>();
129
+ let inputTokens = 0;
130
+ let outputTokens = 0;
131
+ let cacheRead = 0;
132
+ let cacheWrite = 0;
133
+ let totalCost = 0;
134
+ let lastModel = "";
135
+ let lastProvider = "";
136
+
137
+ for (const entry of entries) {
138
+ if (entry.type !== "message") continue;
139
+ const msg = entry.message;
140
+ const ts: number = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
141
+
142
+ if (ts > 0) {
143
+ if (startTime === 0 || ts < startTime) startTime = ts;
144
+ if (ts > endTime) endTime = ts;
145
+ }
146
+
147
+ if (msg.role === "user") {
148
+ userMessages++;
149
+ } else if (msg.role === "assistant") {
150
+ assistantMessages++;
151
+ if (msg.usage) {
152
+ inputTokens += msg.usage.input ?? 0;
153
+ outputTokens += msg.usage.output ?? 0;
154
+ cacheRead += msg.usage.cacheRead ?? 0;
155
+ cacheWrite += msg.usage.cacheWrite ?? 0;
156
+ if (msg.usage.cost) totalCost += msg.usage.cost.total ?? 0;
157
+ }
158
+ if (Array.isArray(msg.content)) {
159
+ for (const block of msg.content) {
160
+ if (block.type === "toolCall") {
161
+ toolCalls++;
162
+ const name: string = block.name ?? "unknown";
163
+ toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
164
+ }
165
+ }
166
+ }
167
+ if (msg.provider) lastProvider = msg.provider;
168
+ if (msg.model) lastModel = msg.model;
169
+ }
170
+ }
171
+
172
+ return {
173
+ sessionName,
174
+ sessionId,
175
+ startTime,
176
+ endTime,
177
+ userMessages,
178
+ assistantMessages,
179
+ toolCalls,
180
+ toolCounts,
181
+ inputTokens,
182
+ outputTokens,
183
+ cacheRead,
184
+ cacheWrite,
185
+ totalCost,
186
+ model: lastModel,
187
+ provider: lastProvider,
188
+ };
189
+ }
190
+
191
+ // ── Stat rows ─────────────────────────────────────────────────────────────────
192
+ //
193
+ // Two row types:
194
+ // info — label + free-form value (session name, model, tool list, etc.)
195
+ // bar — label + right-padded numeric value + fill bar
196
+ // spacer — empty separator line
197
+
198
+ type StatRowType = "info" | "spacer";
199
+
200
+ interface StatRow {
201
+ type: StatRowType;
202
+ label: string; // coloured label
203
+ value: string; // coloured value
204
+ }
205
+
206
+ function buildStatRows(s: SessionStats): StatRow[] {
207
+ const rows: StatRow[] = [];
208
+ const duration = s.endTime > 0 && s.startTime > 0 ? s.endTime - s.startTime : 0;
209
+ const totalTok = s.inputTokens + s.outputTokens + s.cacheRead + s.cacheWrite;
210
+
211
+ if (s.sessionName) {
212
+ rows.push({ type: "info", label: c("session"), value: b(truncate(s.sessionName, 40)) });
213
+ }
214
+ rows.push({ type: "info", label: c("duration"), value: w(formatDuration(duration)) });
215
+ if (s.model) {
216
+ const modelStr = s.provider
217
+ ? `${d(truncate(s.provider, 12) + "/")}${truncate(s.model, 24)}`
218
+ : truncate(s.model, 28);
219
+ rows.push({ type: "info", label: c("model"), value: modelStr });
220
+ }
221
+
222
+ rows.push({ type: "spacer", label: "", value: "" });
223
+
224
+ rows.push({
225
+ type: "info",
226
+ label: c("messages"),
227
+ value: `${g(String(s.userMessages))} user ${bl(String(s.assistantMessages))} asst`,
228
+ });
229
+
230
+ if (s.toolCalls > 0) {
231
+ const topTools = Array.from(s.toolCounts.entries())
232
+ .sort((a, b) => b[1] - a[1])
233
+ .slice(0, 3)
234
+ .map(([n, cnt]) => `${n}${d(" " + String(cnt) + "\u00d7")}`)
235
+ .join(" ");
236
+ rows.push({
237
+ type: "info",
238
+ label: c("tools"),
239
+ value: `${b(String(s.toolCalls))} ${topTools}`,
240
+ });
241
+ }
242
+
243
+ rows.push({ type: "spacer", label: "", value: "" });
244
+
245
+ if (totalTok > 0) {
246
+ rows.push({ type: "info", label: c("input"), value: y(formatTokens(s.inputTokens)) });
247
+ rows.push({ type: "info", label: c("output"), value: g(formatTokens(s.outputTokens)) });
248
+ if (s.cacheRead > 0) {
249
+ rows.push({ type: "info", label: c("c.read"), value: bl(formatTokens(s.cacheRead)) });
250
+ }
251
+ if (s.cacheWrite > 0) {
252
+ rows.push({ type: "info", label: c("c.write"), value: d(formatTokens(s.cacheWrite)) });
253
+ }
254
+ rows.push({ type: "info", label: c("total"), value: b(formatTokens(totalTok)) });
255
+ }
256
+
257
+ if (s.totalCost > 0) {
258
+ rows.push({ type: "spacer", label: "", value: "" });
259
+ const costCol = s.totalCost < 1 ? g : s.totalCost < 5 ? y : m;
260
+ rows.push({ type: "info", label: c("cost"), value: b(costCol(formatCost(s.totalCost))) });
261
+ }
262
+
263
+ // Resume command
264
+ if (s.sessionId) {
265
+ rows.push({ type: "spacer", label: "", value: "" });
266
+ rows.push({ type: "info", label: c("resume"), value: d(`pi --resume=${s.sessionId}`) });
267
+ }
268
+
269
+ return rows;
270
+ }
271
+
272
+ // ── Renderer ───────────────────────────────────────────────────────────────────
273
+
274
+ function render(stats: SessionStats): string {
275
+ const termWidth = process.stdout.columns || 80;
276
+
277
+ // Raccoon art metrics
278
+ const artVisW = Math.max(...RACCOON.map((l) => visLen(l.text)));
279
+ const artGutter = 3;
280
+ const artTotal = artVisW + artGutter;
281
+
282
+ // Show art only when there's at least 48 chars remaining for stats
283
+ const showArt = termWidth >= artTotal + 48;
284
+
285
+ // Build rows
286
+ const rows = buildStatRows(stats);
287
+
288
+ const maxLabelW = Math.max(0, ...rows.filter((r) => r.type !== "spacer").map((r) => visLen(r.label)));
289
+
290
+ // Format each stat line: label (padded) + 2 spaces + value
291
+ const statLines: string[] = rows.map((row) => {
292
+ if (row.type === "spacer") return "";
293
+ const lPad = padTo(row.label, maxLabelW);
294
+ return `${lPad} ${row.value}`;
295
+ });
296
+
297
+ // Max visible width of stat block
298
+ const maxStatVis = Math.max(0, ...statLines.map((l) => visLen(l)));
299
+
300
+ // Final box dimensions
301
+ const contentW = showArt ? artTotal + maxStatVis : maxStatVis;
302
+ const boxInner = Math.min(contentW + 2, termWidth - 2);
303
+ const hLine = "─".repeat(boxInner);
304
+
305
+ // Merge art + stat lines
306
+ const totalLines = Math.max(RACCOON.length, statLines.length);
307
+ const bodyLines: string[] = [];
308
+
309
+ for (let i = 0; i < totalLines; i++) {
310
+ let line = "";
311
+
312
+ if (showArt) {
313
+ const artColored = i < RACCOON.length ? RACCOON[i]!.colored : "";
314
+ const artVisW_ = i < RACCOON.length ? visLen(RACCOON[i]!.text) : 0;
315
+ const gap = artTotal - artVisW_;
316
+ line += artColored + " ".repeat(Math.max(0, gap));
317
+ }
318
+
319
+ line += i < statLines.length ? statLines[i]! : "";
320
+ bodyLines.push(padTo(` ${line}`, boxInner));
321
+ }
322
+
323
+ // Output
324
+ const out: string[] = [];
325
+ out.push("");
326
+ out.push(d(`╭${hLine}╮`));
327
+ for (const bLine of bodyLines) {
328
+ out.push(`${d("│")}${bLine}${d("│")}`);
329
+ }
330
+ out.push(d(`╰${hLine}╯`));
331
+ out.push("");
332
+
333
+ return out.join("\n");
334
+ }
335
+
336
+ // ── Extension ──────────────────────────────────────────────────────────────────
337
+
338
+ export default function quitSummaryExtension(pi: ExtensionAPI): void {
339
+ pi.on("session_shutdown", async (event, ctx) => {
340
+ if (event.reason !== "quit") return;
341
+
342
+ const stats = collectStats(ctx);
343
+ const output = render(stats);
344
+
345
+ // process.on('exit') fires after the TUI teardown restores the original
346
+ // terminal buffer — the summary is visible in the user's shell.
347
+ process.on("exit", () => {
348
+ process.stdout.write(output);
349
+ });
350
+ });
351
+ }