@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.
@@ -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 = (this as { _arnativeTimestamp?: number })._arnativeTimestamp ?? USER_TIMESTAMPS_MAP.get(textKey);
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.replace(/\x1b\[[0-9;]*m/g, "").trimEnd();
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.replace(/\x1b\[[0-9;]*m/g, "").trimEnd();
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 = typeof event.message.content === "string"
171
- ? event.message.content
172
- : event.message.content?.filter((c) => c.type === "text").map((c) => (c as { text: string }).text).join("") ?? "";
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 = ((ctx as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme) ?? 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
- const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
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, "panjang baris pas selebar terminal");
199
- assert(res1.endsWith(badge + " "), "badge berjarak 1 spasi dari ujung kanan");
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"), "jam diikuti spasi margin lalu reset bg");
205
- assert(res2.endsWith("\x1b[49m"), "background ditutup di akhir baris");
206
- assert(visibleWidth(res2) === 30, "panjang baris tetap pas lebar");
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 dipasang ulang setelah reset");
212
- assert(clean.endsWith("\x1b[49m"), "bg bubble ditutup di akhir baris");
213
- assert(repairBubbleBg("plain", "") === "plain", "tanpa bgOpen: tanpa perubahan");
214
- assert(ansiBgOpen(null, "userMessageBg") === "", "tanpa tema: bgOpen kosong");
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 terambil dari probe tema",
249
+ "bgOpen taken from the theme probe",
218
250
  );
219
251
 
220
- // Long text: sliceByColumn keeps the width intact
221
- const res3 = placeTimeAtRight("a".repeat(40), 30, badge, badgeW, false);
222
- assert(visibleWidth(res3) === 30, "teks panjang dipotong pas di targetCol");
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
  }