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 +21 -0
- package/README.md +52 -0
- package/extensions/timestamps.ts +293 -0
- package/package.json +18 -0
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
|
+
}
|