@vanillagreen/pi-qol 2.2.0 → 2.3.2

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,445 @@
1
+ /**
2
+ * The settings reader every pi-extensions package vendors. Each package carries
3
+ * this file byte-identical, and `pi-extensions/package-policy.test.mjs` holds
4
+ * the copies equal: change one, then copy it over every other
5
+ * `package-config.ts` in the tree.
6
+ *
7
+ * It owns the Pi user directory, the project-trust registry and the
8
+ * `kendex.extensionManager.config` walk, and it provides the default
9
+ * settings-file order, `piSettingsPaths`. A package that finds its own project
10
+ * root, such as pi-hooks, passes its own path list to `readPackageConfigAt`.
11
+ * Widgets and renderers read settings many times per frame; going to the disk
12
+ * on each read kept every lane's render loop busy. A memoized answer is served
13
+ * for `SETTINGS_RECHECK_MS` with no filesystem, environment or path work. The
14
+ * memos are module state, so each package keeps its own; only the trust
15
+ * registry is shared by every package in the process.
16
+ * `installSettingsCacheRefresh` drops every answer when the extension manager
17
+ * announces a settings change and when a session starts.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join, resolve } from "node:path";
22
+
23
+ /** What pi-extension-manager emits on `pi.events` after it writes a setting. */
24
+ export const SETTINGS_CHANGED_EVENT = "kendex:extension-settings-changed";
25
+
26
+ /** How long a memoized answer is served before its inputs are read again. */
27
+ export const SETTINGS_RECHECK_MS = 1000;
28
+
29
+ export function expandHome(input: string): string {
30
+ if (input === "~") return homedir();
31
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
32
+ return input;
33
+ }
34
+
35
+ /** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
36
+ * means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
37
+ * where the renderer does not, putting the two on different roots. */
38
+ export function rootAnchored(path: string, windows: boolean): boolean {
39
+ return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/");
40
+ }
41
+
42
+ /** The user directory and the environment it was resolved under. `HOME` and
43
+ * `USERPROFILE` are what `homedir()` reads, on POSIX and on Windows. */
44
+ interface UserDir {
45
+ agentDir: string | undefined;
46
+ home: string | undefined;
47
+ profile: string | undefined;
48
+ dir: string;
49
+ settings: string;
50
+ }
51
+
52
+ let userDirMemo: UserDir | undefined;
53
+
54
+ function userDir(): UserDir {
55
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
56
+ const home = process.env.HOME;
57
+ const profile = process.env.USERPROFILE;
58
+ const known = userDirMemo;
59
+ if (known !== undefined && known.agentDir === agentDir && known.home === home && known.profile === profile) return known;
60
+ const override = expandHome(agentDir?.trim() || "");
61
+ const dir = resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
62
+ userDirMemo = { agentDir, home, profile, dir, settings: join(dir, "settings.json") };
63
+ return userDirMemo;
64
+ }
65
+
66
+ /** A root-anchored `PI_CODING_AGENT_DIR`, else `~/.pi/agent`, for the
67
+ * environment of this call. The settings reads below memoize it instead,
68
+ * since reading the environment costs more than a warm read. */
69
+ export function piUserDir(): string {
70
+ return userDir().dir;
71
+ }
72
+
73
+ interface MemoEntry {
74
+ /** `performance.now()`, a monotonic clock, so a backward wall-clock step
75
+ * cannot hold an entry past its window. */
76
+ readAt: number;
77
+ fingerprint: string | undefined;
78
+ value: unknown;
79
+ }
80
+
81
+ const memo = new Map<string, MemoEntry>();
82
+
83
+ /** Drops every memoized answer so the next read goes back to disk and to the
84
+ * environment. */
85
+ export function clearPackageConfigCache(): void {
86
+ memo.clear();
87
+ cwdSettings.clear();
88
+ userSettingsMemo = undefined;
89
+ }
90
+
91
+ /** The entry for `key` while its window is open. */
92
+ function openEntry(store: Map<string, MemoEntry>, key: string, now: number): MemoEntry | undefined {
93
+ const entry = store.get(key);
94
+ return entry !== undefined && now - entry.readAt < SETTINGS_RECHECK_MS ? entry : undefined;
95
+ }
96
+
97
+ /**
98
+ * `compute()` for `key`, served from memory for `SETTINGS_RECHECK_MS`. Once the
99
+ * window closes, `fingerprint` (when given) runs first, and a fingerprint equal
100
+ * to the one the value was computed under keeps the value, and its identity,
101
+ * for another window without calling `compute`. Without a fingerprint the
102
+ * value is computed again, and so is a value `reusable` answered `false` for,
103
+ * whatever the fingerprint says. `clearPackageConfigCache` drops every entry.
104
+ */
105
+ export function settingsMemo<T>(key: string, compute: () => T, fingerprint?: () => string, reusable?: (value: T) => boolean): T {
106
+ return memoIn(memo, key, performance.now(), compute, fingerprint, reusable);
107
+ }
108
+
109
+ /** `settingsMemo` over `store` at `now`. One public read takes the clock
110
+ * once and hands it down, since reading it costs as much as a warm read. */
111
+ function memoIn<T>(store: Map<string, MemoEntry>, key: string, now: number, compute: () => T, fingerprint?: () => string, reusable?: (value: T) => boolean): T {
112
+ const open = openEntry(store, key, now);
113
+ if (open !== undefined) return open.value as T;
114
+ const entry = store.get(key);
115
+ const print = fingerprint?.();
116
+ if (entry !== undefined && print !== undefined && entry.fingerprint === print) {
117
+ entry.readAt = now;
118
+ return entry.value as T;
119
+ }
120
+ const value = compute();
121
+ store.set(key, { readAt: now, fingerprint: reusable === undefined || reusable(value) ? print : undefined, value });
122
+ return value;
123
+ }
124
+
125
+ /** The project settings file for `cwd`: the nearest ancestor's
126
+ * `.pi/settings.json`, stopping at a `.pi`, `.git` or `.kendex-lock.json`. */
127
+ export function projectSettingsPath(cwd: string): string {
128
+ let current = resolve(cwd);
129
+ while (true) {
130
+ const candidate = join(current, ".pi", "settings.json");
131
+ if (existsSync(candidate)) return candidate;
132
+ if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
133
+ const parent = dirname(current);
134
+ if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
135
+ current = parent;
136
+ }
137
+ }
138
+
139
+ const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
140
+
141
+ interface ProjectTrustRegistry {
142
+ projectSettings?: Map<string, boolean>;
143
+ }
144
+
145
+ /** The registry once found or made; no package replaces it after that. */
146
+ let trustRegistry: ProjectTrustRegistry | undefined;
147
+
148
+ /** Shared by every package in the process: whichever package records Pi's
149
+ * trust answer first, every package reads it. */
150
+ function projectTrustRegistry(): ProjectTrustRegistry {
151
+ if (trustRegistry !== undefined) return trustRegistry;
152
+ const host = globalThis as unknown as Record<PropertyKey, ProjectTrustRegistry | undefined>;
153
+ trustRegistry = host[PROJECT_TRUST_SYMBOL] ?? {};
154
+ host[PROJECT_TRUST_SYMBOL] = trustRegistry;
155
+ return trustRegistry;
156
+ }
157
+
158
+ /**
159
+ * Pi's answer to "has this person trusted this workspace". Only a plain `true`
160
+ * counts: a Pi with no such method, or one that throws, is not trusted. The
161
+ * answer gates reading a project's settings and running a project's own
162
+ * scripts, both safe to withhold and unsafe to grant by accident.
163
+ */
164
+ export function projectTrusted(ctx: { isProjectTrusted?: () => boolean }): boolean {
165
+ try {
166
+ return ctx.isProjectTrusted?.() === true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ /** Records Pi's trust answer against the settings file `settingsPath`. */
173
+ export function recordSettingsTrust(settingsPath: string, ctx: { isProjectTrusted?: () => boolean }): void {
174
+ const registry = projectTrustRegistry();
175
+ if (!registry.projectSettings) registry.projectSettings = new Map();
176
+ registry.projectSettings.set(settingsPath, projectTrusted(ctx));
177
+ }
178
+
179
+ export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
180
+ if (!ctx.cwd) return;
181
+ recordSettingsTrust(projectSettingsPath(ctx.cwd), ctx);
182
+ }
183
+
184
+ /** Read on every call, never memoized: a trust answer recorded by any package
185
+ * takes effect on the next read. */
186
+ export function settingsFileTrusted(settingsPath: string): boolean {
187
+ return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
188
+ }
189
+
190
+ /** The project settings file for `cwd` when Pi trusts the project, else
191
+ * `undefined`. */
192
+ export function trustedProjectSettingsPath(cwd = process.cwd()): string | undefined {
193
+ const view = cwdSettingsAt(cwd, performance.now());
194
+ return view.trusted ? view.project : undefined;
195
+ }
196
+
197
+ export function projectSettingsTrustedForCwd(cwd = process.cwd()): boolean {
198
+ return trustedProjectSettingsPath(cwd) !== undefined;
199
+ }
200
+
201
+ /** The user settings file, resolved from the environment at most once per
202
+ * window. */
203
+ let userSettingsMemo: { readAt: number; path: string } | undefined;
204
+
205
+ function userSettingsPathAt(now: number): string {
206
+ const known = userSettingsMemo;
207
+ if (known !== undefined && now - known.readAt < SETTINGS_RECHECK_MS) return known.path;
208
+ const path = userDir().settings;
209
+ userSettingsMemo = { readAt: now, path };
210
+ return path;
211
+ }
212
+
213
+ /** The user settings file the lists below were built on; a new user directory
214
+ * starts a new set of lists. */
215
+ let pathListsUser: string | undefined;
216
+ const pathLists = new Map<string | undefined, readonly string[]>();
217
+
218
+ /**
219
+ * The user settings file, then `project` when given. One frozen list per
220
+ * answer, so `readSettingsFiles` finds its memo by the list's identity. The
221
+ * caller decides whether `project` is trusted.
222
+ */
223
+ export function userAndProjectSettingsPaths(project: string | undefined): readonly string[] {
224
+ return userAndProjectSettingsPathsAt(project, performance.now());
225
+ }
226
+
227
+ function userAndProjectSettingsPathsAt(project: string | undefined, now: number): readonly string[] {
228
+ const user = userSettingsPathAt(now);
229
+ if (user !== pathListsUser) {
230
+ pathLists.clear();
231
+ pathListsUser = user;
232
+ }
233
+ let paths = pathLists.get(project);
234
+ if (paths === undefined) {
235
+ paths = Object.freeze(project === undefined ? [user] : [user, project]);
236
+ pathLists.set(project, paths);
237
+ }
238
+ return paths;
239
+ }
240
+
241
+ /** The user settings file, then the project's when Pi trusts the project. */
242
+ export function piSettingsPaths(cwd = process.cwd()): readonly string[] {
243
+ return cwdSettingsAt(cwd, performance.now()).paths;
244
+ }
245
+
246
+ export type SettingsRecord = Record<string, unknown>;
247
+
248
+ /** One settings file that exists. A file that does not parse, or that cannot be
249
+ * read for any reason but its absence, is `malformed`; a document that parses
250
+ * to something other than an object reads as an empty one. */
251
+ export type SettingsFile =
252
+ | { kind: "parsed"; path: string; settings: Readonly<SettingsRecord> }
253
+ | { kind: "malformed"; path: string; error: string };
254
+
255
+ function isRecord(value: unknown): value is SettingsRecord {
256
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
257
+ }
258
+
259
+ /** Memoized values are shared by every caller, so a caller that wrote into one
260
+ * would change what the next caller reads. Frozen, that write throws instead. */
261
+ function deepFreeze<T>(value: T): T {
262
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
263
+ Object.freeze(value);
264
+ for (const child of Object.values(value)) deepFreeze(child);
265
+ }
266
+ return value;
267
+ }
268
+
269
+ type FileText = { kind: "absent" } | { kind: "text"; text: string } | { kind: "unreadable"; error: string };
270
+
271
+ function readText(path: string): FileText {
272
+ try {
273
+ return { kind: "text", text: readFileSync(path, "utf8") };
274
+ } catch (error) {
275
+ const code = (error as { code?: unknown } | null)?.code;
276
+ if (code === "ENOENT" || code === "ENOTDIR") return { kind: "absent" };
277
+ return { kind: "unreadable", error: error instanceof Error ? error.message : String(error) };
278
+ }
279
+ }
280
+
281
+ /** Each path list's memo key, so a list a caller keeps is joined once. */
282
+ const settingsFilesKeys = new WeakMap<readonly string[], string>();
283
+
284
+ function settingsFilesKey(paths: readonly string[]): string {
285
+ let key = settingsFilesKeys.get(paths);
286
+ if (key === undefined) {
287
+ key = `settings-files\0${paths.join("\0")}`;
288
+ settingsFilesKeys.set(paths, key);
289
+ }
290
+ return key;
291
+ }
292
+
293
+ /**
294
+ * The files at `paths` that exist, in order. Each window re-reads their text;
295
+ * text equal to what the last parse saw keeps the last answer, so an unchanged
296
+ * file is parsed once and its callers keep one identity for it.
297
+ */
298
+ export function readSettingsFiles(paths: readonly string[]): readonly SettingsFile[] {
299
+ return readSettingsFilesAt(paths, performance.now());
300
+ }
301
+
302
+ function readSettingsFilesAt(paths: readonly string[], now: number): readonly SettingsFile[] {
303
+ const key = settingsFilesKey(paths);
304
+ // Checked before the closures below exist, so a warm read allocates nothing.
305
+ const open = openEntry(memo, key, now);
306
+ if (open !== undefined) return open.value as readonly SettingsFile[];
307
+ let texts: FileText[] = [];
308
+ return memoIn(
309
+ memo,
310
+ key,
311
+ now,
312
+ () =>
313
+ deepFreeze(
314
+ paths.flatMap((path, index): SettingsFile[] => {
315
+ const text = texts[index] ?? readText(path);
316
+ if (text.kind === "absent") return [];
317
+ if (text.kind === "unreadable") return [{ kind: "malformed", path, error: text.error }];
318
+ try {
319
+ const parsed: unknown = JSON.parse(text.text);
320
+ return [{ kind: "parsed", path, settings: isRecord(parsed) ? parsed : {} }];
321
+ } catch (error) {
322
+ return [{ kind: "malformed", path, error: error instanceof Error ? error.message : String(error) }];
323
+ }
324
+ }),
325
+ ),
326
+ () => {
327
+ texts = paths.map(readText);
328
+ return JSON.stringify(texts);
329
+ },
330
+ );
331
+ }
332
+
333
+ /** `packageId`'s object under `kendex.extensionManager.config` in one parsed
334
+ * settings document, or `undefined` where it holds none. */
335
+ export function packageConfigIn(settings: Readonly<SettingsRecord>, packageId: string): Readonly<SettingsRecord> | undefined {
336
+ const kendex = settings.kendex;
337
+ const manager = isRecord(kendex) ? kendex.extensionManager : undefined;
338
+ const config = isRecord(manager) ? manager.config : undefined;
339
+ const own = isRecord(config) ? config[packageId] : undefined;
340
+ return isRecord(own) ? own : undefined;
341
+ }
342
+
343
+ /** What reads for one cwd resolve to: its project settings file, the trust
344
+ * answer its paths were built under, the files at those paths and the configs
345
+ * merged over them. */
346
+ interface CwdSettings {
347
+ readAt: number;
348
+ project: string;
349
+ trusted: boolean;
350
+ paths: readonly string[];
351
+ files: readonly SettingsFile[];
352
+ configs: Map<string, Readonly<SettingsRecord>>;
353
+ }
354
+
355
+ const cwdSettings = new Map<string, CwdSettings>();
356
+
357
+ /**
358
+ * The settings for `cwd`, served for `SETTINGS_RECHECK_MS` with one map
359
+ * lookup and the trust lookup: no environment, path or file work. Trust is
360
+ * read on every call, so an answer any package records applies at once. Once
361
+ * the window closes, the project walk runs again, and the user settings file
362
+ * and the files come from their own memos.
363
+ */
364
+ function cwdSettingsAt(cwd: string, now: number): CwdSettings {
365
+ const known = cwdSettings.get(cwd);
366
+ const open = known !== undefined && now - known.readAt < SETTINGS_RECHECK_MS ? known : undefined;
367
+ if (open !== undefined && settingsFileTrusted(open.project) === open.trusted) return open;
368
+ const project = open !== undefined ? open.project : projectSettingsPath(cwd);
369
+ const trusted = settingsFileTrusted(project);
370
+ const paths = userAndProjectSettingsPathsAt(trusted ? project : undefined, now);
371
+ const files = readSettingsFilesAt(paths, now);
372
+ // The files may come from a window another cwd opened; this view closes
373
+ // with it, so an edit is never served for longer than one window.
374
+ const filesReadAt = memo.get(settingsFilesKey(paths))?.readAt ?? now;
375
+ const readAt = Math.min(open !== undefined ? open.readAt : now, filesReadAt);
376
+ const view: CwdSettings = { readAt, project, trusted, paths, files, configs: configsOver(files) };
377
+ cwdSettings.set(cwd, view);
378
+ return view;
379
+ }
380
+
381
+ /** Merged configs per package id, keyed by the file list they were merged
382
+ * from: `readSettingsFiles` hands back a new list whenever a file changed. */
383
+ const configsByFiles = new WeakMap<readonly SettingsFile[], Map<string, Readonly<SettingsRecord>>>();
384
+
385
+ /** `packageId`'s config over the files at `paths`, later files overriding
386
+ * earlier ones key by key. A malformed file contributes nothing. */
387
+ export function readPackageConfigAt(packageId: string, paths: readonly string[]): Readonly<SettingsRecord> {
388
+ const files = readSettingsFiles(paths);
389
+ return packageConfigOver(packageId, files, configsOver(files));
390
+ }
391
+
392
+ function configsOver(files: readonly SettingsFile[]): Map<string, Readonly<SettingsRecord>> {
393
+ let configs = configsByFiles.get(files);
394
+ if (!configs) {
395
+ configs = new Map();
396
+ configsByFiles.set(files, configs);
397
+ }
398
+ return configs;
399
+ }
400
+
401
+ function packageConfigOver(packageId: string, files: readonly SettingsFile[], configs: Map<string, Readonly<SettingsRecord>>): Readonly<SettingsRecord> {
402
+ const known = configs.get(packageId);
403
+ if (known) return known;
404
+ const merged: SettingsRecord = {};
405
+ for (const file of files) {
406
+ if (file.kind !== "parsed") continue;
407
+ const config = packageConfigIn(file.settings, packageId);
408
+ if (config) Object.assign(merged, config);
409
+ }
410
+ const frozen = Object.freeze(merged);
411
+ configs.set(packageId, frozen);
412
+ return frozen;
413
+ }
414
+
415
+ /** `packageId`'s config over `piSettingsPaths(cwd)`. */
416
+ export function readPackageConfig(packageId: string, cwd = process.cwd()): Readonly<SettingsRecord> {
417
+ const view = cwdSettingsAt(cwd, performance.now());
418
+ return packageConfigOver(packageId, view.files, view.configs);
419
+ }
420
+
421
+ /** The part of Pi's `ExtensionAPI` the refresh needs. */
422
+ export interface SettingsRefreshHost {
423
+ on(event: "session_start", handler: () => void): unknown;
424
+ on(event: "session_shutdown", handler: () => void): unknown;
425
+ events: { on(channel: string, handler: (data: unknown) => void): () => void };
426
+ }
427
+
428
+ /**
429
+ * Drops every memoized answer when a session starts, which is where the cwd
430
+ * and the project's trust can change, and whenever pi-extension-manager
431
+ * announces a settings change. The subscription lives from `session_start` to
432
+ * `session_shutdown`, as Pi asks of any listener.
433
+ */
434
+ export function installSettingsCacheRefresh(pi: SettingsRefreshHost): void {
435
+ let unsubscribe: (() => void) | undefined;
436
+ pi.on("session_start", () => {
437
+ clearPackageConfigCache();
438
+ unsubscribe ??= pi.events.on(SETTINGS_CHANGED_EVENT, () => clearPackageConfigCache());
439
+ });
440
+ pi.on("session_shutdown", () => {
441
+ unsubscribe?.();
442
+ unsubscribe = undefined;
443
+ clearPackageConfigCache();
444
+ });
445
+ }
@@ -9,6 +9,19 @@ interface PendingQueueThemePatch {
9
9
  cwd?: string;
10
10
  }
11
11
 
12
+ interface StatusTextAlignmentPatch {
13
+ originalRender: (this: unknown, width: number) => string[];
14
+ }
15
+
16
+ function isStatusTextAlignmentPatch(value: unknown): value is StatusTextAlignmentPatch {
17
+ return typeof value === "object" && value !== null && typeof (value as Partial<StatusTextAlignmentPatch>).originalRender === "function";
18
+ }
19
+
20
+ interface StatusTextClassification {
21
+ text: string;
22
+ status: boolean;
23
+ }
24
+
12
25
  function isPendingQueuePreviewText(text: string): boolean {
13
26
  const plain = stripAnsi(text);
14
27
  return plain.startsWith("Steering: ") || plain.startsWith("Follow-up: ");
@@ -28,15 +41,33 @@ function isQueuedMessageStatusText(text: string): boolean {
28
41
  return /^Restored \d+ queued messages? to editor$/.test(plain) || plain === "No queued messages to restore";
29
42
  }
30
43
 
31
- export function installStatusTextAlignmentPatch(): void {
44
+ /**
45
+ * Pads Pi's dequeue status line ("Restored N queued messages to editor") flush
46
+ * with the pending-queue preview. The patch wraps every pi-tui `Text`, so the
47
+ * classification is cached per instance and recomputed only when its text
48
+ * changes: an unchanged `Text`, which pi-tui answers from its own render
49
+ * cache, pays one string comparison, not a scan of its whole text.
50
+ */
51
+ export function installStatusTextAlignmentPatch(ctx: ExtensionContext): void {
52
+ if (!ctx.hasUI) return;
32
53
  const proto = Text.prototype as unknown as Record<PropertyKey, any>;
33
- if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL]) return;
54
+ // Any marker already here keeps its wrapper, including pi-qol 2.2.0's `true`.
55
+ if (proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] !== undefined) return;
34
56
  const originalRender = proto.render;
35
57
  if (typeof originalRender !== "function") return;
36
- proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = true;
58
+ const classifications = new WeakMap<object, StatusTextClassification>();
59
+ const isStatusText = (component: object, text: string): boolean => {
60
+ const cached = classifications.get(component);
61
+ if (cached && cached.text === text) return cached.status;
62
+ const status = isQueuedMessageStatusText(text);
63
+ classifications.set(component, { text, status });
64
+ return status;
65
+ };
66
+ const patch: StatusTextAlignmentPatch = { originalRender };
67
+ proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL] = patch;
37
68
  proto.render = function patchedQolStatusTextRender(this: any, width: number): string[] {
38
69
  const text = typeof this?.text === "string" ? this.text : "";
39
- if (!isQueuedMessageStatusText(text)) return originalRender.call(this, width);
70
+ if (!isStatusText(this, text)) return originalRender.call(this, width);
40
71
  const originalPaddingX = this.paddingX;
41
72
  try {
42
73
  this.paddingX = 0;
@@ -49,6 +80,20 @@ export function installStatusTextAlignmentPatch(): void {
49
80
  };
50
81
  }
51
82
 
83
+ /**
84
+ * Removes the patch, but only one this module's install wrote. Any other
85
+ * marker keeps its wrapper: pi-qol 2.2.0 marked the prototype with `true` and
86
+ * kept no original render, and Pi's `/reload` carries that marker into this
87
+ * module because pi-tui's prototype outlives the reload.
88
+ */
89
+ export function restoreStatusTextAlignmentPatch(): void {
90
+ const proto = Text.prototype as unknown as Record<PropertyKey, any>;
91
+ const patch: unknown = proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL];
92
+ if (!isStatusTextAlignmentPatch(patch)) return;
93
+ proto.render = patch.originalRender;
94
+ delete proto[STATUS_TEXT_ALIGNMENT_PATCH_SYMBOL];
95
+ }
96
+
52
97
  export function installPendingQueueThemePatch(ctx: ExtensionContext): void {
53
98
  if (!ctx.hasUI) return;
54
99
  const proto = Theme.prototype as unknown as Record<PropertyKey, unknown>;
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs";
1
+ import { realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -8,8 +8,12 @@ import {
8
8
  DEFAULT_SESSION_SEARCH_SHORTCUT,
9
9
  SESSION_SEARCH_PENDING_SYMBOL,
10
10
  SESSION_SEARCH_STATUS_KEY,
11
+ SESSION_SEARCH_TEXT_MAX_CHARS,
12
+ SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION,
13
+ SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS,
11
14
  } from "../constants.js";
12
- import { expandHome, piSettingsPaths, settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
15
+ import { expandHome, piSettingsPaths, readSettingsFiles } from "../package-config.js";
16
+ import { settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
13
17
  import { forEachSessionJsonlLine } from "./jsonl.js";
14
18
  import type {
15
19
  QolSessionPaletteAction,
@@ -23,12 +27,33 @@ import type {
23
27
  let qolSessionSearchCache: QolSessionSearchSession[] = [];
24
28
  let qolSessionSearchLoadedAt = 0;
25
29
  let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
30
+ let qolSessionSearchReleaseTimer: ReturnType<typeof setTimeout> | undefined;
31
+ /** Parsed user prompts per session path, oldest insertion first; at most
32
+ * SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS entries. */
26
33
  const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
27
- export const qolSessionSearchPendingActions = new Map<string, QolSessionPaletteAction>();
34
+ /** The resume or fork action the editor's `/search:resume-pending <id>` line
35
+ * names. Queuing another replaces it, since the editor holds one line. */
36
+ let qolSessionSearchPendingAction: { id: string; action: QolSessionPaletteAction } | undefined;
28
37
  let qolSessionSearchPendingActionCounter = 0;
29
38
 
30
- export function nextSessionSearchPendingActionId(): string {
31
- return `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
39
+ /** Hold `action` as the one pending action and return the id that claims it. */
40
+ export function queueSessionSearchPendingAction(action: QolSessionPaletteAction): string {
41
+ const id = `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
42
+ qolSessionSearchPendingAction = { id, action };
43
+ return id;
44
+ }
45
+
46
+ /** Remove and return the pending action when `id` names it. */
47
+ export function takeSessionSearchPendingAction(id: string): QolSessionPaletteAction | undefined {
48
+ if (qolSessionSearchPendingAction?.id !== id) return undefined;
49
+ const { action } = qolSessionSearchPendingAction;
50
+ qolSessionSearchPendingAction = undefined;
51
+ return action;
52
+ }
53
+
54
+ /** Drop the pending action. Runs on session shutdown. */
55
+ export function clearSessionSearchPendingAction(): void {
56
+ qolSessionSearchPendingAction = undefined;
32
57
  }
33
58
 
34
59
  export function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
@@ -82,16 +107,10 @@ function configuredSessionDir(cwd: string): string | undefined {
82
107
  const envDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
83
108
  if (envDir) return resolveSettingsRelativePath(envDir, join(resolve(cwd), ".pi", "settings.json"));
84
109
  let configured: string | undefined;
85
- for (const settingsPath of piSettingsPaths(cwd)) {
86
- if (!existsSync(settingsPath)) continue;
87
- try {
88
- const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
89
- if (typeof parsed?.sessionDir === "string" && parsed.sessionDir.trim()) {
90
- configured = resolveSettingsRelativePath(parsed.sessionDir, settingsPath);
91
- }
92
- } catch {
93
- // Ignore malformed optional settings.
94
- }
110
+ for (const file of readSettingsFiles(piSettingsPaths(cwd))) {
111
+ if (file.kind !== "parsed") continue;
112
+ const sessionDir = file.settings.sessionDir;
113
+ if (typeof sessionDir === "string" && sessionDir.trim()) configured = resolveSettingsRelativePath(sessionDir, file.path);
95
114
  }
96
115
  return configured;
97
116
  }
@@ -113,16 +132,40 @@ export function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
113
132
  return settingString("sessionSearch.defaultScope", "current", cwd).toLowerCase() === "all" ? "all" : "current";
114
133
  }
115
134
 
135
+ /** Cap the message text the index keeps: each session's text at the per-session
136
+ * limit, and the sum at the index limit, filled newest session first. */
137
+ export function capSessionSearchText(sessions: QolSessionSearchSession[]): QolSessionSearchSession[] {
138
+ let budget = SESSION_SEARCH_TEXT_MAX_CHARS;
139
+ const newestFirst = [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime());
140
+ for (const session of newestFirst) {
141
+ const kept = session.allMessagesText.slice(0, Math.min(SESSION_SEARCH_TEXT_MAX_CHARS_PER_SESSION, budget));
142
+ budget -= kept.length;
143
+ session.allMessagesText = kept;
144
+ }
145
+ return sessions;
146
+ }
147
+
116
148
  async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
117
149
  const customSessionDir = configuredSessionDir(ctx.cwd);
118
150
  const infos = customSessionDir
119
151
  ? await SessionManager.list(ctx.cwd, customSessionDir, onProgress)
120
152
  : await SessionManager.listAll(onProgress);
121
- return infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined);
153
+ return capSessionSearchText(infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined));
154
+ }
155
+
156
+ /** Drop the loaded index and the parsed prompts. Runs when the index outlives
157
+ * its TTL and on session shutdown; the next search loads the index again. */
158
+ export function releaseQolSessionSearchCache(): void {
159
+ if (qolSessionSearchReleaseTimer) clearTimeout(qolSessionSearchReleaseTimer);
160
+ qolSessionSearchReleaseTimer = undefined;
161
+ qolSessionSearchCache = [];
162
+ qolSessionSearchLoadedAt = 0;
163
+ qolSessionUserMessagesCache.clear();
122
164
  }
123
165
 
124
166
  export async function refreshQolSessionSearchCache(ctx: ExtensionContext, options?: { force?: boolean; quiet?: boolean }): Promise<QolSessionSearchSession[]> {
125
167
  const ttlMs = Math.max(0, settingNumber("sessionSearch.cacheTtlSeconds", DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS, ctx.cwd) * 1000);
168
+ // A TTL of 0 keeps the index until session shutdown.
126
169
  const fresh = qolSessionSearchCache.length > 0 && (ttlMs === 0 || Date.now() - qolSessionSearchLoadedAt < ttlMs);
127
170
  if (!options?.force && fresh) return qolSessionSearchCache;
128
171
  if (qolSessionSearchLoading) return qolSessionSearchLoading;
@@ -131,9 +174,13 @@ export async function refreshQolSessionSearchCache(ctx: ExtensionContext, option
131
174
  qolSessionSearchLoading = loadQolSessionSearchSessions(ctx, (loaded, total) => {
132
175
  if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Loading sessions ${loaded}/${total}`);
133
176
  }).then((sessions) => {
177
+ releaseQolSessionSearchCache();
134
178
  qolSessionSearchCache = sessions;
135
179
  qolSessionSearchLoadedAt = Date.now();
136
- qolSessionUserMessagesCache.clear();
180
+ if (ttlMs > 0) {
181
+ qolSessionSearchReleaseTimer = setTimeout(releaseQolSessionSearchCache, ttlMs);
182
+ qolSessionSearchReleaseTimer.unref?.();
183
+ }
137
184
  return sessions;
138
185
  }).finally(() => {
139
186
  qolSessionSearchLoading = undefined;
@@ -187,6 +234,10 @@ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[
187
234
  // Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
188
235
  }
189
236
  qolSessionUserMessagesCache.set(sessionPath, messages);
237
+ for (const oldest of qolSessionUserMessagesCache.keys()) {
238
+ if (qolSessionUserMessagesCache.size <= SESSION_SEARCH_USER_MESSAGES_MAX_SESSIONS) break;
239
+ qolSessionUserMessagesCache.delete(oldest);
240
+ }
190
241
  return messages;
191
242
  }
192
243