@bismawy/pi-arnative 0.2.2 → 0.2.4
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/CHANGELOG.md +81 -0
- package/README.md +18 -2
- package/extensions/footer.ts +48 -67
- package/extensions/section-headers.ts +196 -129
- package/extensions/timestamps.ts +75 -31
- package/extensions/tools.ts +157 -147
- package/extensions/ui-render-tweaks.ts +101 -119
- package/extensions/usage.ts +17 -25
- package/lib/ansi.ts +22 -9
- package/lib/box.ts +8 -13
- package/lib/check.ts +12 -0
- package/lib/format.ts +33 -0
- package/lib/usage-store.ts +3 -1
- package/package.json +2 -1
- package/themes/gen-themes.mjs +38 -38
package/extensions/timestamps.ts
CHANGED
|
@@ -12,9 +12,38 @@
|
|
|
12
12
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
13
13
|
import { AssistantMessageComponent, UserMessageComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
-
import { ansiBgOpen } from "../lib/ansi.ts";
|
|
15
|
+
import { ansiBgOpen, stripAnsi } from "../lib/ansi.ts";
|
|
16
|
+
import { assert, isMain } from "../lib/check.ts";
|
|
16
17
|
|
|
17
18
|
const USER_TIMESTAMPS_MAP = new Map<string, number>();
|
|
19
|
+
let getBranch: (() => readonly unknown[]) | null = null;
|
|
20
|
+
let prefilled = false;
|
|
21
|
+
|
|
22
|
+
function userTextOf(message: { content?: unknown }): string {
|
|
23
|
+
const c = message.content;
|
|
24
|
+
if (typeof c === "string") return c;
|
|
25
|
+
if (Array.isArray(c)) {
|
|
26
|
+
return c
|
|
27
|
+
.filter((x) => (x as { type?: string })?.type === "text")
|
|
28
|
+
.map((x) => (x as { text?: string }).text ?? "")
|
|
29
|
+
.join("");
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Restore/reload: history entries never fire message_start, so the map is prefilled
|
|
35
|
+
// once from the session branch before the first render lookup. Keyed by trimmed text
|
|
36
|
+
// (last write wins) — ponytail: duplicate texts share one clock, key by message id if
|
|
37
|
+
// pi ever exposes one at render time.
|
|
38
|
+
function prefillUserTimestamps(entries: readonly unknown[]): void {
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const entry = e as { message?: { role?: string; content?: unknown; timestamp?: number }; timestamp?: string };
|
|
41
|
+
const m = entry.message;
|
|
42
|
+
if (m?.role !== "user") continue;
|
|
43
|
+
const text = userTextOf(m).trim();
|
|
44
|
+
if (text) USER_TIMESTAMPS_MAP.set(text, m.timestamp || Date.parse(entry.timestamp ?? "") || Date.now());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
18
47
|
let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
|
|
19
48
|
|
|
20
49
|
function formatClock(timestamp?: number): string {
|
|
@@ -84,15 +113,24 @@ if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
|
|
|
84
113
|
const bgOpen = ansiBgOpen(activeThemeProxy, "userMessageBg");
|
|
85
114
|
for (let i = 0; i < lines.length; i++) lines[i] = repairBubbleBg(lines[i], bgOpen);
|
|
86
115
|
|
|
116
|
+
// Restore/reload safe: old user messages arrive without message_start events,
|
|
117
|
+
// so fill the map once from the session history before the first lookup.
|
|
118
|
+
if (!prefilled && getBranch) {
|
|
119
|
+
prefillUserTimestamps(getBranch());
|
|
120
|
+
prefilled = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
87
123
|
const textKey = (this as { text?: string }).text?.trim() ?? "";
|
|
88
|
-
const ts =
|
|
124
|
+
const ts = USER_TIMESTAMPS_MAP.get(textKey);
|
|
125
|
+
// No known timestamp -> no clock (a "now" clock would be a lie on old messages).
|
|
126
|
+
if (!ts) return lines;
|
|
89
127
|
const timeStr = formatClock(ts);
|
|
90
128
|
const badge = formatTimeBadge(timeStr, activeThemeProxy);
|
|
91
129
|
const timeW = visibleWidth(timeStr);
|
|
92
130
|
|
|
93
131
|
// First text line sits at index 1 (below the box top padding)
|
|
94
132
|
const contentLine = lines[1];
|
|
95
|
-
const cleanContent = contentLine
|
|
133
|
+
const cleanContent = stripAnsi(contentLine).trimEnd();
|
|
96
134
|
const contentW = visibleWidth(cleanContent);
|
|
97
135
|
const minGap = 2;
|
|
98
136
|
|
|
@@ -150,7 +188,7 @@ if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
|
|
|
150
188
|
rest = rest.slice(prefix.length);
|
|
151
189
|
}
|
|
152
190
|
|
|
153
|
-
const cleanText = rest
|
|
191
|
+
const cleanText = stripAnsi(rest).trimEnd();
|
|
154
192
|
const textW = visibleWidth(cleanText);
|
|
155
193
|
const minGap = 2;
|
|
156
194
|
|
|
@@ -167,58 +205,64 @@ export default function (pi: ExtensionAPI) {
|
|
|
167
205
|
// Record the user message timestamp (read by the UserMessageComponent render above)
|
|
168
206
|
pi.on("message_start", async (event) => {
|
|
169
207
|
if (event.message.role !== "user") return;
|
|
170
|
-
const text =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (text.trim()) {
|
|
174
|
-
USER_TIMESTAMPS_MAP.set(text.trim(), event.message.timestamp || Date.now());
|
|
208
|
+
const text = userTextOf(event.message).trim();
|
|
209
|
+
if (text) {
|
|
210
|
+
USER_TIMESTAMPS_MAP.set(text, event.message.timestamp || Date.now());
|
|
175
211
|
}
|
|
176
212
|
});
|
|
177
213
|
|
|
178
214
|
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
179
215
|
pi.on("session_start", async (_event, ctx) => {
|
|
180
|
-
activeThemeProxy =
|
|
216
|
+
activeThemeProxy = themeOf<typeof activeThemeProxy>(ctx) ?? activeThemeProxy;
|
|
217
|
+
// History getter for the restore prefill (render-time, once).
|
|
218
|
+
getBranch = () => ctx.sessionManager.getBranch();
|
|
219
|
+
prefilled = false;
|
|
181
220
|
});
|
|
182
221
|
}
|
|
183
222
|
|
|
184
223
|
// Self-check: `node extensions/timestamps.ts`
|
|
185
|
-
|
|
186
|
-
if (isMain) {
|
|
187
|
-
const assert = (cond: boolean, msg: string) => {
|
|
188
|
-
if (!cond) {
|
|
189
|
-
console.error(`FAIL: ${msg}`);
|
|
190
|
-
process.exit(1);
|
|
191
|
-
}
|
|
192
|
-
};
|
|
224
|
+
if (isMain(import.meta.url)) {
|
|
193
225
|
const badge = "\x1b[36m17:09\x1b[39m";
|
|
194
226
|
const badgeW = 5;
|
|
195
227
|
|
|
196
228
|
// Clock flush right with a 1-space margin
|
|
197
229
|
const res1 = placeTimeAtRight("Short text", 30, badge, badgeW, "");
|
|
198
|
-
assert(visibleWidth(res1) === 30, "
|
|
199
|
-
assert(res1.endsWith(badge + " "), "badge
|
|
230
|
+
assert(visibleWidth(res1) === 30, "line length matches the terminal width");
|
|
231
|
+
assert(res1.endsWith(badge + " "), "badge sits 1 space from the right edge");
|
|
200
232
|
|
|
201
233
|
// With the user bubble background
|
|
202
234
|
const bg = "\x1b[48;2;52;53;61m";
|
|
203
235
|
const res2 = placeTimeAtRight(`${bg}Short text`, 30, badge, badgeW, bg);
|
|
204
|
-
assert(res2.includes(badge + " \x1b[49m"), "
|
|
205
|
-
assert(res2.endsWith("\x1b[49m"), "background
|
|
206
|
-
assert(visibleWidth(res2) === 30, "
|
|
236
|
+
assert(res2.includes(badge + " \x1b[49m"), "hours, margin space, then bg reset");
|
|
237
|
+
assert(res2.endsWith("\x1b[49m"), "background closed at end of line");
|
|
238
|
+
assert(visibleWidth(res2) === 30, "line length still matches the width");
|
|
207
239
|
|
|
208
240
|
// bubble bg repair: padding after [0m] must be bg'd, line closed with [49m]
|
|
209
241
|
const dirty = `${bg}Hello\x1b[0m${" ".repeat(5)}\x1b[49m`;
|
|
210
242
|
const clean = repairBubbleBg(dirty, bg);
|
|
211
|
-
assert(clean.includes(`\x1b[0m${bg}`), "bg
|
|
212
|
-
assert(clean.endsWith("\x1b[49m"), "bg
|
|
213
|
-
assert(repairBubbleBg("plain", "") === "plain", "
|
|
214
|
-
assert(ansiBgOpen(null, "userMessageBg") === "", "
|
|
243
|
+
assert(clean.includes(`\x1b[0m${bg}`), "bg re-applied after the reset");
|
|
244
|
+
assert(clean.endsWith("\x1b[49m"), "bubble bg closed at end of line");
|
|
245
|
+
assert(repairBubbleBg("plain", "") === "plain", "no bgOpen: unchanged");
|
|
246
|
+
assert(ansiBgOpen(null, "userMessageBg") === "", "no theme: empty bgOpen");
|
|
215
247
|
assert(
|
|
216
248
|
ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;52;53;61m${t}\x1b[49m` }, "userMessageBg") === "\x1b[48;2;52;53;61m",
|
|
217
|
-
"bgOpen
|
|
249
|
+
"bgOpen taken from the theme probe",
|
|
218
250
|
);
|
|
219
251
|
|
|
220
|
-
// Long text: sliceByColumn keeps the width intact
|
|
221
|
-
const
|
|
222
|
-
|
|
252
|
+
// Long text: sliceByColumn keeps the width intact (bg branch: badge area carries the bubble bg)
|
|
253
|
+
const bgOpenTest = "\x1b[48;2;52;53;61m";
|
|
254
|
+
const res3 = placeTimeAtRight("a".repeat(40), 30, badge, badgeW, bgOpenTest);
|
|
255
|
+
assert(visibleWidth(res3) === 30, "long text sliced exactly at targetCol");
|
|
256
|
+
assert(res3.includes(bgOpenTest), "bg branch: bubble bg applied in the badge area");
|
|
257
|
+
|
|
258
|
+
// History prefill: old user messages get their real clock on restore
|
|
259
|
+
assert(userTextOf({ content: "plain" }) === "plain", "userTextOf string content");
|
|
260
|
+
assert(userTextOf({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }) === "ab", "userTextOf text parts joined");
|
|
261
|
+
prefillUserTimestamps([
|
|
262
|
+
{ type: "message", timestamp: "2026-01-02T03:04:05.000Z", message: { role: "user", content: "old question", timestamp: 1000 } },
|
|
263
|
+
{ type: "message", timestamp: "not-a-date", message: { role: "user", content: [{ type: "text", text: "old reply" }] } },
|
|
264
|
+
]);
|
|
265
|
+
assert(USER_TIMESTAMPS_MAP.get("old question") === 1000, "prefill stores the message timestamp");
|
|
266
|
+
assert((USER_TIMESTAMPS_MAP.get("old reply") ?? 0) > 0, "prefill falls back to the entry timestamp");
|
|
223
267
|
console.log("timestamps.ts self-check OK");
|
|
224
268
|
}
|