@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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +38 -0
- 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
|
+
}
|