@bismawy/pi-arnative 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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Chat transcript clock (time only, not a full timestamp):
3
+ * - User: right-aligned in the last column, accent color, no background.
4
+ * Content is never truncated; if the first line is full the clock moves to the
5
+ * bubble's TOP padding line.
6
+ * - Assistant: clock only on the final reply (a message with no toolCall), not on
7
+ * Thinking... or intermediate turns.
8
+ *
9
+ * Also patches the user bubble bg: Markdown text carries \x1b[0m mid-line, so
10
+ * padding after the reset renders dark (a black block).
11
+ */
12
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
13
+ import { AssistantMessageComponent, UserMessageComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { ansiBgOpen } from "../lib/ansi.ts";
16
+
17
+ const USER_TIMESTAMPS_MAP = new Map<string, number>();
18
+ let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
19
+
20
+ function formatClock(timestamp?: number): string {
21
+ const d = timestamp && timestamp > 0 ? new Date(timestamp) : new Date();
22
+ const h = String(d.getHours()).padStart(2, "0");
23
+ const m = String(d.getMinutes()).padStart(2, "0");
24
+ return `${h}:${m}`;
25
+ }
26
+
27
+ export function formatTimeBadge(timeStr: string, th: { fg(color: string, text: string): string } | null): string {
28
+ if (th) {
29
+ try {
30
+ return th.fg("accent", timeStr);
31
+ } catch {
32
+ // fallback
33
+ }
34
+ }
35
+ return `\x1b[36m${timeStr}\x1b[39m`;
36
+ }
37
+
38
+ // User bubble bg open code (no closing reset) -> lib/ansi.ts (single definition).
39
+
40
+ // Root cause of the "black block" in a full chat: Markdown text carries a mid-line
41
+ // \x1b[0m (full reset), so padding AFTER the reset loses the bubble bg and renders
42
+ // dark for the rest of the line. Re-apply the bubble bg after every reset and close
43
+ // it at end of line so it cannot leak. Same pattern as InputBgEditor below.
44
+ export function repairBubbleBg(line: string, bgOpen: string): string {
45
+ if (!bgOpen) return line;
46
+ const patched = line.split("\x1b[0m").join(`\x1b[0m${bgOpen}`).split("\x1b[49m").join(`\x1b[49m${bgOpen}`);
47
+ return patched.endsWith(bgOpen) ? `${patched}\x1b[49m` : patched;
48
+ }
49
+
50
+ /**
51
+ * Paste the clock at the right end of a line, 1 column from the right edge.
52
+ * When `bgOpen` is given (e.g. user chat) the padding and the clock use that bubble
53
+ * background, so there is no black block / clipped box.
54
+ */
55
+ export function placeTimeAtRight(
56
+ line: string,
57
+ width: number,
58
+ timeBadge: string,
59
+ timeW: number,
60
+ bgOpen = "",
61
+ ): string {
62
+ const margin = 1;
63
+ const targetCol = Math.max(0, width - timeW - margin);
64
+ const leftPart = sliceByColumn(line, 0, targetCol, true);
65
+ const leftW = visibleWidth(leftPart);
66
+ const pad = " ".repeat(Math.max(0, targetCol - leftW));
67
+ const bg = bgOpen ? bgOpen : "";
68
+ const reset = bgOpen ? "\x1b[49m" : "";
69
+ return `${leftPart}${bg}${pad}${timeBadge} ${reset}`;
70
+ }
71
+
72
+ const CHAT_TIMESTAMP_PATCHED = Symbol.for("pi-arnative.chatTimestampPatched");
73
+ if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
74
+ (globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED] = true;
75
+
76
+ if (UserMessageComponent?.prototype?.render) {
77
+ const origRender = UserMessageComponent.prototype.render;
78
+ UserMessageComponent.prototype.render = function (width: number): string[] {
79
+ const lines = origRender.call(this, width);
80
+ if (lines.length < 2) return lines;
81
+
82
+ // Fix the bubble bg first (reset Markdown lines make padding dark),
83
+ // then paste the clock (which deliberately drops the bg in the badge area).
84
+ const bgOpen = ansiBgOpen(activeThemeProxy, "userMessageBg");
85
+ for (let i = 0; i < lines.length; i++) lines[i] = repairBubbleBg(lines[i], bgOpen);
86
+
87
+ const textKey = (this as { text?: string }).text?.trim() ?? "";
88
+ const ts = (this as { _arnativeTimestamp?: number })._arnativeTimestamp ?? USER_TIMESTAMPS_MAP.get(textKey);
89
+ const timeStr = formatClock(ts);
90
+ const badge = formatTimeBadge(timeStr, activeThemeProxy);
91
+ const timeW = visibleWidth(timeStr);
92
+
93
+ // First text line sits at index 1 (below the box top padding)
94
+ const contentLine = lines[1];
95
+ const cleanContent = contentLine.replace(/\x1b\[[0-9;]*m/g, "").trimEnd();
96
+ const contentW = visibleWidth(cleanContent);
97
+ const minGap = 2;
98
+
99
+ if (contentW + minGap + timeW <= width) {
100
+ // Fits on the first text line: paste right-aligned with a 1-space margin
101
+ lines[1] = placeTimeAtRight(contentLine, width, badge, timeW, bgOpen);
102
+ } else {
103
+ // First text line is full: NEVER truncate content!
104
+ // Clock goes to the TOP padding line (always empty, first line of the bubble)
105
+ const topLine = lines[0];
106
+ const oscMatch = topLine.match(/^(\x1b\]133;[A-Z]\x07)+/);
107
+ const prefix = oscMatch ? oscMatch[0] : "";
108
+ const rest = topLine.slice(prefix.length);
109
+ lines[0] = `${prefix}${placeTimeAtRight(rest, width, badge, timeW, bgOpen)}`;
110
+ }
111
+ return lines;
112
+ };
113
+ }
114
+
115
+ if (AssistantMessageComponent?.prototype?.render) {
116
+ const origRender = AssistantMessageComponent.prototype.render;
117
+ AssistantMessageComponent.prototype.render = function (width: number): string[] {
118
+ const lines = origRender.call(this, width);
119
+ const lastMsg = (this as { lastMessage?: AssistantMessage }).lastMessage;
120
+
121
+ // Clock only on the final reply (a message with no toolCall)
122
+ const hasToolCalls = lastMsg?.content?.some((c) => c.type === "toolCall");
123
+ if (hasToolCalls) return lines;
124
+
125
+ // and only when there is real answer text (not thinking only)
126
+ const hasText = lastMsg?.content?.some((c) => c.type === "text" && c.text.trim());
127
+ if (!hasText || lines.length === 0) return lines;
128
+
129
+ const timeStr = formatClock(lastMsg?.timestamp);
130
+ const badge = formatTimeBadge(timeStr, activeThemeProxy);
131
+ const timeW = visibleWidth(timeStr);
132
+
133
+ // First text line that is neither an OSC zone nor a thinking header
134
+ let targetIdx = -1;
135
+ for (let i = 0; i < lines.length; i++) {
136
+ const cleaned = lines[i].replace(/^(\x1b\]133;[A-Z]\x07)+/, "").trim();
137
+ // Never paste onto the "Thinking..." line
138
+ if (cleaned.length > 0 && !/^thinking\b/i.test(cleaned)) {
139
+ targetIdx = i;
140
+ break;
141
+ }
142
+ }
143
+
144
+ if (targetIdx !== -1) {
145
+ let prefix = "";
146
+ let rest = lines[targetIdx];
147
+ const oscMatch = rest.match(/^(\x1b\]133;[A-Z]\x07)+/);
148
+ if (oscMatch) {
149
+ prefix = oscMatch[0];
150
+ rest = rest.slice(prefix.length);
151
+ }
152
+
153
+ const cleanText = rest.replace(/\x1b\[[0-9;]*m/g, "").trimEnd();
154
+ const textW = visibleWidth(cleanText);
155
+ const minGap = 2;
156
+
157
+ if (textW + minGap + timeW <= width) {
158
+ lines[targetIdx] = `${prefix}${placeTimeAtRight(rest, width, badge, timeW, "")}`;
159
+ }
160
+ }
161
+ return lines;
162
+ };
163
+ }
164
+ }
165
+
166
+ export default function (pi: ExtensionAPI) {
167
+ // Record the user message timestamp (read by the UserMessageComponent render above)
168
+ pi.on("message_start", async (event) => {
169
+ 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());
175
+ }
176
+ });
177
+
178
+ // The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
179
+ pi.on("session_start", async (_event, ctx) => {
180
+ activeThemeProxy = ((ctx as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme) ?? activeThemeProxy;
181
+ });
182
+ }
183
+
184
+ // 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
+ };
193
+ const badge = "\x1b[36m17:09\x1b[39m";
194
+ const badgeW = 5;
195
+
196
+ // Clock flush right with a 1-space margin
197
+ 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");
200
+
201
+ // With the user bubble background
202
+ const bg = "\x1b[48;2;52;53;61m";
203
+ 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");
207
+
208
+ // bubble bg repair: padding after [0m] must be bg'd, line closed with [49m]
209
+ const dirty = `${bg}Hello\x1b[0m${" ".repeat(5)}\x1b[49m`;
210
+ 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");
215
+ assert(
216
+ 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",
218
+ );
219
+
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");
223
+ console.log("timestamps.ts self-check OK");
224
+ }