@heyhuynhgiabuu/pi-pretty 0.6.24 → 0.6.25

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/src/index.ts CHANGED
@@ -9,7 +9,15 @@
9
9
  * • Custom ANSI rendering for all tools
10
10
  */
11
11
 
12
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import type {
15
+ ExtensionAPI,
16
+ ExtensionCommandContext,
17
+ ExtensionContext,
18
+ MessageUpdateEvent,
19
+ SessionInfoChangedEvent,
20
+ } from "@earendil-works/pi-coding-agent";
13
21
  import * as hostSdk from "@earendil-works/pi-coding-agent";
14
22
  import { createFffAutocompleteProvider } from "./autocomplete.js";
15
23
  import { applyConfig, getDefaultAgentDir, loadConfig, normalizeToolList, resolveToolSets } from "./config.js";
@@ -20,6 +28,16 @@ import { registerGrepTool } from "./tools/grep.js";
20
28
  import { registerLsTool } from "./tools/ls.js";
21
29
  import { registerReadTool } from "./tools/read.js";
22
30
  import type { PiPrettyDeps, SdkTools } from "./types.js";
31
+ import {
32
+ createThinkingLabelAnimator,
33
+ installWorkingIndicator,
34
+ resolveThinkingIndicatorSettings,
35
+ resolveWorkingIndicatorSettings,
36
+ type ThinkingLabelAnimator,
37
+ thinkingBlockActive,
38
+ WORKING_INTERVAL_MS,
39
+ type WorkingIndicatorController,
40
+ } from "./working-indicator.js";
23
41
 
24
42
  // ---------------------------------------------------------------------------
25
43
  // Config
@@ -139,8 +157,70 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
139
157
  // Session lifecycle
140
158
  // ------------------------------------------------------------------
141
159
 
160
+ const workingSettings = resolveWorkingIndicatorSettings(config.workingIndicator);
161
+ const thinkingSettings = resolveThinkingIndicatorSettings(config.thinkingIndicator);
162
+ let workingController: WorkingIndicatorController | undefined;
163
+ let thinkingAnimator: ThinkingLabelAnimator | undefined;
164
+ let thinkingInterval: ReturnType<typeof setInterval> | undefined;
165
+ let thinkingLastMessage: unknown;
166
+ let thinkingHiddenCache = true;
167
+ let thinkingHiddenCheckedAt = 0;
168
+ let workingSessionName: string | undefined;
169
+ let workingStreaming = false;
170
+
171
+ /** pi persists the thinking-visibility toggle as a top-level settings key. */
172
+ const thinkingHidden = (): boolean => {
173
+ try {
174
+ const dir = getDefaultAgentDir();
175
+ if (!dir) return false;
176
+ const parsed = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")) as {
177
+ hideThinkingBlock?: unknown;
178
+ };
179
+ return parsed.hideThinkingBlock === true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ };
184
+
185
+ const safeSessionName = (ctx: ExtensionContext): string | undefined => {
186
+ try {
187
+ return ctx.sessionManager?.getSessionName?.() ?? undefined;
188
+ } catch {
189
+ return undefined;
190
+ }
191
+ };
192
+
193
+ // (Re-)install the widget: new session, reload, or session rename (the
194
+ // accent tint tracks the name, omp-style). Preserves streaming state so a
195
+ // mid-stream rename does not drop the row.
196
+ const installIndicator = async (ctx: ExtensionContext): Promise<void> => {
197
+ workingController?.dispose();
198
+ workingController = undefined;
199
+ try {
200
+ const controller = await installWorkingIndicator(ctx.ui, workingSettings, undefined, workingSessionName);
201
+ workingController = controller;
202
+ if (workingStreaming) controller.start();
203
+ // Noop install (disabled/blank) after a previous install → restore pi's loader.
204
+ if (controller.frames.length === 0) ctx.ui.setWorkingVisible?.(true);
205
+ } catch (error: unknown) {
206
+ try {
207
+ ctx.ui.setWorkingVisible?.(true);
208
+ } catch {
209
+ // Host without the API — nothing to restore.
210
+ }
211
+ ctx.ui?.notify?.(
212
+ `pi-pretty working indicator failed: ${error instanceof Error ? error.message : String(error)}`,
213
+ "warning",
214
+ );
215
+ }
216
+ };
217
+
142
218
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
143
- if (ctx.mode === "tui") ctx.ui.setToolsExpanded(false);
219
+ if (ctx.mode === "tui") {
220
+ ctx.ui.setToolsExpanded(false);
221
+ workingSessionName = safeSessionName(ctx);
222
+ await installIndicator(ctx);
223
+ }
144
224
 
145
225
  if (!fffService) return;
146
226
 
@@ -169,7 +249,102 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
169
249
  }
170
250
  });
171
251
 
252
+ // Re-apply the indicator when the session is (re)named so the accent tint
253
+ // tracks the name; omp derives the accent the same way.
254
+ pi.on("session_info_changed", async (event: SessionInfoChangedEvent, ctx: ExtensionContext) => {
255
+ if (ctx.mode !== "tui") return;
256
+ if (event.name === workingSessionName) return;
257
+ workingSessionName = event.name;
258
+ if (workingSettings.enabled) await installIndicator(ctx);
259
+ });
260
+
261
+ // Drive the widget with the streaming lifecycle (host loader is hidden).
262
+ pi.on("agent_start", async (_event: unknown, ctx: ExtensionContext) => {
263
+ if (ctx.mode !== "tui") return;
264
+ workingStreaming = true;
265
+ workingController?.start();
266
+ });
267
+ const stopStreaming = async (_event: unknown, ctx: ExtensionContext): Promise<void> => {
268
+ if (ctx.mode !== "tui") return;
269
+ workingStreaming = false;
270
+ workingController?.stop();
271
+ stopThinkingShimmer();
272
+ };
273
+ pi.on("agent_end", stopStreaming);
274
+ pi.on("agent_settled", stopStreaming);
275
+
276
+ const stopThinkingShimmer = (): void => {
277
+ if (thinkingInterval) {
278
+ clearInterval(thinkingInterval);
279
+ thinkingInterval = undefined;
280
+ }
281
+ thinkingAnimator?.restore();
282
+ thinkingAnimator = undefined;
283
+ thinkingLastMessage = undefined;
284
+ };
285
+
286
+ // Same 30fps cadence as the working row — but the interval lives ONLY while
287
+ // the streaming message is actively emitting a thinking block (each label
288
+ // change rebuilds chat children, so the moment text or tool calls stream the
289
+ // shimmer stops and pi's default label is restored).
290
+ const startThinkingShimmer = (ctx: ExtensionContext): void => {
291
+ if (thinkingInterval || !thinkingSettings.enabled) return;
292
+ try {
293
+ thinkingAnimator = createThinkingLabelAnimator(ctx.ui, workingSettings, workingSessionName, thinkingSettings);
294
+ thinkingHiddenCache = thinkingHidden();
295
+ thinkingHiddenCheckedAt = Date.now();
296
+ if (!thinkingHiddenCache) {
297
+ stopThinkingShimmer();
298
+ return;
299
+ }
300
+ thinkingAnimator.tick();
301
+ thinkingInterval = setInterval(() => {
302
+ if (!workingStreaming) {
303
+ stopThinkingShimmer();
304
+ return;
305
+ }
306
+ const now = Date.now();
307
+ if (now - thinkingHiddenCheckedAt > 500) {
308
+ thinkingHiddenCache = thinkingHidden();
309
+ thinkingHiddenCheckedAt = now;
310
+ if (!thinkingHiddenCache) {
311
+ stopThinkingShimmer();
312
+ return;
313
+ }
314
+ }
315
+ if (!thinkingBlockActive(thinkingLastMessage)) {
316
+ // Thinking phase over: restore pi's default label and stop.
317
+ stopThinkingShimmer();
318
+ return;
319
+ }
320
+ thinkingAnimator?.tick();
321
+ }, WORKING_INTERVAL_MS);
322
+ } catch (error: unknown) {
323
+ stopThinkingShimmer();
324
+ ctx.ui?.notify?.(
325
+ `pi-pretty thinking indicator failed: ${error instanceof Error ? error.message : String(error)}`,
326
+ "warning",
327
+ );
328
+ }
329
+ };
330
+
331
+ // Remember the streaming message; lazily start the shimmer on the first
332
+ // thinking delta (thinkingHidden() is re-checked at most every 500ms).
333
+ pi.on("message_update", async (event: MessageUpdateEvent, ctx: ExtensionContext) => {
334
+ if (ctx.mode !== "tui" || !workingStreaming) return;
335
+ thinkingLastMessage = event.message;
336
+ if (thinkingInterval) return;
337
+ if (!thinkingBlockActive(event.message)) return;
338
+ startThinkingShimmer(ctx);
339
+ });
340
+
172
341
  pi.on("session_shutdown", async () => {
342
+ // Tear down the widget animation; pi re-runs session_start (and our
343
+ // install) after resume or session switching.
344
+ workingController?.dispose();
345
+ workingController = undefined;
346
+ stopThinkingShimmer();
347
+ workingStreaming = false;
173
348
  // Intentionally keep the native FFF finder on session shutdown.
174
349
  // Pi can emit shutdown/start during resume or session switching while the
175
350
  // process keeps running. Native teardown, or dropping the JS handle while the
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Per-session accent color — port of oh-my-pi's `utils/session-color.ts`
3
+ * (OKLCH essence, no native dependencies).
4
+ *
5
+ * omp derives a stable per-session accent: the session name hash picks only
6
+ * the *hue* (djb2, walked along a dark-theme-safe arc that excludes hues
7
+ * which darken badly); lightness and chroma live in OKLCH so every hue
8
+ * renders with equal perceived weight (the reason omp carries the theme
9
+ * accent through per-hue gamut cusps — constant OKLCH L/C achieves the same
10
+ * uniformity without theme introspection, which pi's extension API does not
11
+ * expose). The spinner variant dims the accent exactly like omp's
12
+ * `adjustHsv(hex, { s: 0.55, v: 0.65 })`.
13
+ *
14
+ * Skipped from the omp original (requires host theme internals pi does not
15
+ * expose to extensions): per-hue gamut cusps, WCAG contrast bisection against
16
+ * the surface, hue-collision avoidance against theme colors.
17
+ */
18
+
19
+ // ─── omp session-color.ts constants ──────────────────────────────────────────
20
+
21
+ /** omp MIN_CHROMA / MAX_CHROMA: readable floor, "not a highlighter set" ceiling. */
22
+ const MIN_CHROMA = 0.05;
23
+ const MAX_CHROMA = 0.21;
24
+
25
+ /** omp dark-theme lightness band (visible on dark surfaces). */
26
+ const LIGHTNESS = 0.75;
27
+
28
+ /**
29
+ * Dark-theme hue arc. omp computes these intervals from per-hue gamut cusps;
30
+ * its doc comment pins the always-excluded bands — the yellow/chartreuse core
31
+ * (≈94–138°, darkens to mustard) and the over-light cyan peak (≈158–200°) —
32
+ * for every theme, so the arc below hardcodes the full wheel minus those two.
33
+ */
34
+ const DARK_HUE_INTERVALS: ReadonlyArray<readonly [number, number]> = [
35
+ [0, 93],
36
+ [139, 157],
37
+ [201, 359],
38
+ ];
39
+ const ARC_LENGTH = DARK_HUE_INTERVALS.reduce((n, [a, b]) => n + (b - a + 1), 0);
40
+
41
+ /** omp nameToHash: stable 32-bit djb2. */
42
+ function nameToHash(name: string): number {
43
+ let hash = 5381;
44
+ for (let i = 0; i < name.length; i++) {
45
+ hash = ((hash << 5) + hash) ^ name.charCodeAt(i);
46
+ hash = hash >>> 0;
47
+ }
48
+ return hash;
49
+ }
50
+
51
+ /** Map a name to its OKLCH hue on the dark-safe arc. */
52
+ export function sessionAccentHue(name: string): number {
53
+ let pos = nameToHash(name) % ARC_LENGTH;
54
+ for (const [a, b] of DARK_HUE_INTERVALS) {
55
+ const n = b - a + 1;
56
+ if (pos < n) return a + pos;
57
+ pos -= n;
58
+ }
59
+ return DARK_HUE_INTERVALS[DARK_HUE_INTERVALS.length - 1]?.[1] ?? 0;
60
+ }
61
+
62
+ // ─── OKLCH ↔ sRGB (Björn Ottosson's published matrices) ─────────────────────
63
+
64
+ function oklchToSrgb(l: number, c: number, hDeg: number): [number, number, number] {
65
+ const h = (hDeg * Math.PI) / 180;
66
+ const a = c * Math.cos(h);
67
+ const b = c * Math.sin(h);
68
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
69
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
70
+ const s_ = l - 0.0894841775 * a - 1.291485548 * b;
71
+ const ll = l_ * l_ * l_;
72
+ const mm = m_ * m_ * m_;
73
+ const ss = s_ * s_ * s_;
74
+ return [
75
+ 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss,
76
+ -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss,
77
+ -0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss,
78
+ ];
79
+ }
80
+
81
+ const transfer = (x: number): number => (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055);
82
+ const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));
83
+
84
+ /** Largest chroma ≤ `c` whose color is inside the sRGB gamut (chroma bisection). */
85
+ function clampChroma(l: number, c: number, hDeg: number, iterations = 12): number {
86
+ let lo = 0;
87
+ let hi = c;
88
+ const inGamut = (chroma: number): boolean => oklchToSrgb(l, chroma, hDeg).every((x) => x >= -1e-4 && x <= 1 + 1e-4);
89
+ if (inGamut(hi)) return hi;
90
+ for (let i = 0; i < iterations; i++) {
91
+ const mid = (lo + hi) / 2;
92
+ if (inGamut(mid)) lo = mid;
93
+ else hi = mid;
94
+ }
95
+ return lo;
96
+ }
97
+
98
+ function oklchToHex(l: number, c: number, hDeg: number): string {
99
+ const chroma = clampChroma(l, c, hDeg);
100
+ const [r, g, b] = oklchToSrgb(l, chroma, hDeg);
101
+ const hex = (x: number): string =>
102
+ Math.round(clamp01(transfer(clamp01(x))) * 255)
103
+ .toString(16)
104
+ .padStart(2, "0");
105
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
106
+ }
107
+
108
+ // ─── Public surface ──────────────────────────────────────────────────────────
109
+
110
+ /** Stable per-session accent hex at a fixed OKLCH lightness/chroma band. */
111
+ export function sessionAccentHex(name: string): string {
112
+ return oklchToHex(LIGHTNESS, Math.max(MIN_CHROMA, Math.min(MAX_CHROMA, 0.14)), sessionAccentHue(name));
113
+ }
114
+
115
+ function hexToRgb(hex: string): [number, number, number] | undefined {
116
+ const m = hex.trim().match(/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
117
+ if (!m) return undefined;
118
+ return [Number.parseInt(m[1], 16), Number.parseInt(m[2], 16), Number.parseInt(m[3], 16)];
119
+ }
120
+
121
+ /** RGB(0-1) → HSV; exactly the inverse legs omp's adjustHsv needs. */
122
+ function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
123
+ const max = Math.max(r, g, b);
124
+ const min = Math.min(r, g, b);
125
+ const d = max - min;
126
+ let h: number;
127
+ if (d === 0) h = 0;
128
+ else if (max === r) h = ((g - b) / d) % 6;
129
+ else if (max === g) h = (b - r) / d + 2;
130
+ else h = (r - g) / d + 4;
131
+ h = (h * 60 + 360) % 360;
132
+ return [h, max === 0 ? 0 : d / max, max];
133
+ }
134
+
135
+ function hsvToHex(h: number, s: number, v: number): string {
136
+ const c = v * s;
137
+ const hp = h / 60;
138
+ const x = c * (1 - Math.abs((hp % 2) - 1));
139
+ const m = v - c;
140
+ let rgb: [number, number, number];
141
+ if (hp < 1) rgb = [c, x, 0];
142
+ else if (hp < 2) rgb = [x, c, 0];
143
+ else if (hp < 3) rgb = [0, c, x];
144
+ else if (hp < 4) rgb = [0, x, c];
145
+ else if (hp < 5) rgb = [x, 0, c];
146
+ else rgb = [c, 0, x];
147
+ const hex = (x: number): string =>
148
+ Math.round((x + m) * 255)
149
+ .toString(16)
150
+ .padStart(2, "0");
151
+ return `#${hex(rgb[0])}${hex(rgb[1])}${hex(rgb[2])}`;
152
+ }
153
+
154
+ /**
155
+ * Dim variant for the spinner — identical transform to omp's
156
+ * `adjustHsv(hex, { s: 0.55, v: 0.65 })`. Unparseable input returns
157
+ * the input unchanged.
158
+ */
159
+ export function dimAccentHex(hex: string): string {
160
+ const rgb = hexToRgb(hex);
161
+ if (!rgb) return hex;
162
+ const [r, g, b] = rgb.map((x) => x / 255) as [number, number, number];
163
+ const [h, s, v] = rgbToHsv(r, g, b);
164
+ return hsvToHex(h, s * 0.55, v * 0.65);
165
+ }
166
+
167
+ /** Truecolor ANSI foreground open sequence for a `#rrggbb` hex. */
168
+ export function hexToAnsiFg(hex: string): string {
169
+ const rgb = hexToRgb(hex);
170
+ if (!rgb) return "";
171
+ return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
172
+ }