pi-timestamps 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eyal En Gad
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,52 @@
1
+ # pi-timestamps
2
+
3
+ Timestamps extension for the [pi coding agent](https://github.com/badlogic/pi-mono).
4
+
5
+ ## Features
6
+
7
+ - **Timing widget** — shows prompt time (↑), response time (↓), and duration (⏱) above the editor
8
+ - **Live elapsed timer** — footer status shows `⏱ 12.3s` while the agent is streaming
9
+ - **`/timestamps` command** — interactive timeline browser with search and message preview
10
+
11
+ ## Use Cases
12
+
13
+ - Estimate elapsed time when running long processes — compiles, test suites, model training, deployments
14
+ - Spot slow prompts or unexpectedly long agent responses at a glance
15
+ - Review session timing after the fact with the `/timestamps` timeline browser
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pi install npm:pi-timestamps
21
+ ```
22
+
23
+ Or try without installing:
24
+
25
+ ```bash
26
+ pi -e npm:pi-timestamps
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ Optionally set a timezone by creating `~/.pi/agent/timestamps.json`:
32
+
33
+ ```json
34
+ {
35
+ "timeZone": "America/Los_Angeles"
36
+ }
37
+ ```
38
+
39
+ Any [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) works (e.g. `America/New_York`, `Europe/London`, `Asia/Tokyo`). Omit the file to use your system default.
40
+
41
+ ## Usage
42
+
43
+ The widget and timer work automatically. Use `/timestamps` to browse the full message timeline:
44
+
45
+ - **↑↓** — navigate messages
46
+ - **Type** — search/filter
47
+ - **Backspace** — clear search
48
+ - **Escape** — close
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Timestamps Extension for pi coding agent
3
+ *
4
+ * - Widget above editor: prompt time → response time (duration)
5
+ * - Footer status: live elapsed timer during streaming
6
+ * - /timestamps command: interactive message timeline browser
7
+ *
8
+ * Configure timezone via ~/.pi/agent/timestamps.json:
9
+ * { "timeZone": "America/Los_Angeles" }
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
13
+ import { matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
14
+
15
+ const CONFIG_PATH = `${process.env.HOME}/.pi/agent/timestamps.json`;
16
+
17
+ function loadConfig(): { timeZone?: string } {
18
+ try {
19
+ return JSON.parse(require("node:fs").readFileSync(CONFIG_PATH, "utf-8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function formatTime(date: Date): string {
26
+ const config = loadConfig();
27
+ const opts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit" };
28
+ if (config.timeZone) opts.timeZone = config.timeZone;
29
+ return date.toLocaleTimeString([], opts);
30
+ }
31
+
32
+ function formatDuration(ms: number): string {
33
+ if (ms < 1000) return `${ms}ms`;
34
+ const s = ms / 1000;
35
+ if (s < 60) return `${s.toFixed(1)}s`;
36
+ const m = Math.floor(s / 60);
37
+ const rem = s - m * 60;
38
+ return `${m}m ${rem.toFixed(0)}s`;
39
+ }
40
+
41
+ export default function (pi: ExtensionAPI) {
42
+ let promptTime: Date | null = null;
43
+ let responseTime: Date | null = null;
44
+ let agentStartMs: number | null = null;
45
+ let timerInterval: ReturnType<typeof setInterval> | null = null;
46
+ let lastCtx: ExtensionContext | null = null;
47
+
48
+ function clearTimer() {
49
+ if (timerInterval) {
50
+ clearInterval(timerInterval);
51
+ timerInterval = null;
52
+ }
53
+ }
54
+
55
+ function updateWidget(ctx: ExtensionContext) {
56
+ if (!ctx.hasUI) return;
57
+ const theme = ctx.ui.theme;
58
+
59
+ if (!promptTime) {
60
+ ctx.ui.setWidget("timestamps", undefined);
61
+ return;
62
+ }
63
+
64
+ let line = theme.fg("dim", "↑ ") + theme.fg("dim", formatTime(promptTime));
65
+
66
+ if (responseTime && agentStartMs) {
67
+ const duration = responseTime.getTime() - agentStartMs;
68
+ line += theme.fg("dim", " ↓ ") + theme.fg("dim", formatTime(responseTime));
69
+ line += theme.fg("dim", " ⏱ ") + theme.fg("dim", formatDuration(duration));
70
+ }
71
+
72
+ ctx.ui.setWidget("timestamps", [line]);
73
+ }
74
+
75
+ pi.on("before_agent_start", async (_event, ctx) => {
76
+ promptTime = new Date();
77
+ responseTime = null;
78
+ agentStartMs = promptTime.getTime();
79
+ lastCtx = ctx;
80
+ updateWidget(ctx);
81
+ });
82
+
83
+ pi.on("turn_start", async (_event, ctx) => {
84
+ lastCtx = ctx;
85
+ clearTimer();
86
+ timerInterval = setInterval(() => {
87
+ if (!agentStartMs || !lastCtx) return;
88
+ const elapsed = Date.now() - agentStartMs;
89
+ const theme = lastCtx.ui.theme;
90
+ lastCtx.ui.setStatus("timestamps", theme.fg("dim", `⏱ ${formatDuration(elapsed)}`));
91
+ }, 100);
92
+ });
93
+
94
+ pi.on("turn_end", async (_event, ctx) => {
95
+ clearTimer();
96
+ });
97
+
98
+ pi.on("agent_end", async (_event, ctx) => {
99
+ clearTimer();
100
+ responseTime = new Date();
101
+ if (agentStartMs) {
102
+ const total = Date.now() - agentStartMs;
103
+ const theme = ctx.ui.theme;
104
+ ctx.ui.setStatus("timestamps", theme.fg("success", "✓ ") + theme.fg("dim", formatDuration(total)));
105
+ }
106
+ updateWidget(ctx);
107
+ });
108
+
109
+ pi.on("session_start", async (_event, ctx) => {
110
+ promptTime = null;
111
+ responseTime = null;
112
+ agentStartMs = null;
113
+ lastCtx = ctx;
114
+ clearTimer();
115
+ });
116
+
117
+ pi.on("session_switch", async (_event, ctx) => {
118
+ promptTime = null;
119
+ responseTime = null;
120
+ agentStartMs = null;
121
+ clearTimer();
122
+ ctx.ui.setWidget("timestamps", undefined);
123
+ ctx.ui.setStatus("timestamps", "");
124
+ });
125
+
126
+ pi.on("session_shutdown", async () => {
127
+ clearTimer();
128
+ });
129
+
130
+ // --- /timestamps command: interactive message timeline ---
131
+ pi.registerCommand("timestamps", {
132
+ description: "Browse message timestamps interactively",
133
+ handler: async (_args, ctx) => {
134
+ const entries = ctx.sessionManager.getBranch();
135
+
136
+ interface MsgEntry {
137
+ time: string;
138
+ role: string;
139
+ arrow: string;
140
+ preview: string;
141
+ fullText: string;
142
+ }
143
+
144
+ const msgs: MsgEntry[] = [];
145
+ for (const entry of entries) {
146
+ if (entry.type !== "message") continue;
147
+ const msg = entry.message;
148
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
149
+ const ts = entry.timestamp;
150
+ if (!ts) continue;
151
+
152
+ let text = "";
153
+ if (typeof msg.content === "string") {
154
+ text = msg.content;
155
+ } else if (Array.isArray(msg.content)) {
156
+ const textBlock = msg.content.find((b: any) => b.type === "text");
157
+ text = textBlock?.text ?? "";
158
+ }
159
+
160
+ msgs.push({
161
+ time: formatTime(new Date(typeof ts === "string" ? Date.parse(ts) : ts)),
162
+ role: msg.role,
163
+ arrow: msg.role === "user" ? "↑" : "↓",
164
+ preview: text.replace(/\n/g, " ").slice(0, 80),
165
+ fullText: text,
166
+ });
167
+ }
168
+
169
+ if (msgs.length === 0) {
170
+ ctx.ui.notify("No messages in session yet", "info");
171
+ return;
172
+ }
173
+
174
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
175
+ let selected = msgs.length - 1;
176
+ let search = "";
177
+ let cachedWidth: number | undefined;
178
+ let cachedLines: string[] | undefined;
179
+
180
+ function getFiltered(): MsgEntry[] {
181
+ if (!search) return msgs;
182
+ const q = search.toLowerCase();
183
+ return msgs.filter(
184
+ (m) =>
185
+ m.fullText.toLowerCase().includes(q) ||
186
+ m.role.includes(q) ||
187
+ m.time.includes(q),
188
+ );
189
+ }
190
+
191
+ return {
192
+ render(width: number): string[] {
193
+ if (cachedLines && cachedWidth === width) return cachedLines;
194
+
195
+ const filtered = getFiltered();
196
+ const lines: string[] = [];
197
+ const termH = tui.height ?? 24;
198
+
199
+ // Search bar
200
+ if (search) {
201
+ lines.push(truncateToWidth(theme.fg("accent", "🔍 ") + theme.fg("text", search), width));
202
+ } else {
203
+ lines.push(truncateToWidth(theme.fg("dim", "type to search • ↑↓ navigate • esc close"), width));
204
+ }
205
+ lines.push(theme.fg("dim", "─".repeat(Math.min(width, 60))));
206
+
207
+ if (filtered.length === 0) {
208
+ lines.push(theme.fg("warning", " No matches"));
209
+ cachedWidth = width;
210
+ cachedLines = lines;
211
+ return lines;
212
+ }
213
+
214
+ // Clamp selected
215
+ if (selected >= filtered.length) selected = filtered.length - 1;
216
+ if (selected < 0) selected = 0;
217
+
218
+ // Split space: top half for list, bottom half for preview
219
+ const availH = termH - 4;
220
+ const listH = Math.max(3, Math.floor(availH * 0.4));
221
+ const previewH = Math.max(3, availH - listH - 1);
222
+
223
+ // Scrollable list — keep selected item centered
224
+ let scrollStart = selected - Math.floor(listH / 2);
225
+ if (scrollStart < 0) scrollStart = 0;
226
+ if (scrollStart > filtered.length - listH) scrollStart = Math.max(0, filtered.length - listH);
227
+
228
+ for (let i = scrollStart; i < Math.min(scrollStart + listH, filtered.length); i++) {
229
+ const m = filtered[i]!;
230
+ const pointer = i === selected ? theme.fg("accent", "▸ ") : " ";
231
+ const timeStr = theme.fg("dim", m.time);
232
+ const arrow = theme.fg("dim", ` ${m.arrow} `);
233
+ const role = theme.fg(m.role === "user" ? "accent" : "success", m.role);
234
+ const preview = theme.fg(i === selected ? "text" : "dim", `: ${m.preview}`);
235
+ lines.push(truncateToWidth(pointer + timeStr + arrow + role + preview, width));
236
+ }
237
+
238
+ if (filtered.length > listH) {
239
+ lines.push(theme.fg("dim", ` (${filtered.length} messages, showing ${scrollStart + 1}-${Math.min(scrollStart + listH, filtered.length)})`));
240
+ }
241
+
242
+ // Separator
243
+ lines.push(theme.fg("dim", "─".repeat(Math.min(width, 60))));
244
+
245
+ // Preview of selected message
246
+ const sel = filtered[selected]!;
247
+ const previewLines = sel.fullText.split("\n");
248
+ for (let i = 0; i < Math.min(previewH, previewLines.length); i++) {
249
+ lines.push(truncateToWidth(theme.fg("text", previewLines[i]!), width));
250
+ }
251
+ if (previewLines.length > previewH) {
252
+ lines.push(theme.fg("dim", ` … ${previewLines.length - previewH} more lines`));
253
+ }
254
+
255
+ cachedWidth = width;
256
+ cachedLines = lines;
257
+ return lines;
258
+ },
259
+
260
+ handleInput(data: string) {
261
+ if (matchesKey(data, Key.escape)) {
262
+ done();
263
+ } else if (matchesKey(data, Key.up)) {
264
+ const filtered = getFiltered();
265
+ if (selected > 0) selected--;
266
+ else selected = filtered.length - 1;
267
+ } else if (matchesKey(data, Key.down)) {
268
+ const filtered = getFiltered();
269
+ if (selected < filtered.length - 1) selected++;
270
+ else selected = 0;
271
+ } else if (matchesKey(data, Key.backspace)) {
272
+ search = search.slice(0, -1);
273
+ selected = 0;
274
+ } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
275
+ search += data;
276
+ selected = 0;
277
+ } else {
278
+ return;
279
+ }
280
+ cachedWidth = undefined;
281
+ cachedLines = undefined;
282
+ tui.requestRender();
283
+ },
284
+
285
+ invalidate() {
286
+ cachedWidth = undefined;
287
+ cachedLines = undefined;
288
+ },
289
+ };
290
+ });
291
+ },
292
+ });
293
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "pi-timestamps",
3
+ "version": "0.1.0",
4
+ "description": "Timestamps extension for pi coding agent — message timing widget, elapsed timer, and interactive timeline browser",
5
+ "keywords": ["pi-package", "pi-extension", "timestamps", "pi-coding-agent"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/eengad/pi-timestamps.git"
10
+ },
11
+ "peerDependencies": {
12
+ "@mariozechner/pi-coding-agent": "*",
13
+ "@mariozechner/pi-tui": "*"
14
+ },
15
+ "pi": {
16
+ "extensions": ["./extensions"]
17
+ }
18
+ }