@vanillagreen/pi-qol 2.2.1 → 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.
@@ -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
 
@@ -29,6 +29,7 @@ import type {
29
29
  QolSessionMessagesState,
30
30
  QolSessionPaletteAction,
31
31
  QolSessionSearchHit,
32
+ QolSessionSearchLoad,
32
33
  QolSessionSearchResult,
33
34
  QolSessionSearchScope,
34
35
  QolSessionSearchSession,
@@ -108,7 +109,8 @@ export class QolSessionSearchComponent {
108
109
  private readonly done: (action: QolSessionPaletteAction) => void;
109
110
  private readonly tui: { requestRender(): void; terminal?: { rows?: number } };
110
111
  private readonly theme: Theme;
111
- private readonly sessions: QolSessionSearchSession[];
112
+ /** The index, while it loads, once loaded, or the load's failure. */
113
+ private sessions: QolSessionSearchLoad;
112
114
  private readonly cwd: string;
113
115
  private readonly currentModel: QolModelInfo | undefined;
114
116
  private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" | "confirmModel" = "search";
@@ -125,7 +127,7 @@ export class QolSessionSearchComponent {
125
127
  done: (action: QolSessionPaletteAction) => void,
126
128
  tui: { requestRender(): void; terminal?: { rows?: number } },
127
129
  theme: Theme,
128
- sessions: QolSessionSearchSession[],
130
+ sessions: QolSessionSearchLoad,
129
131
  cwd: string,
130
132
  initialQuery = "",
131
133
  currentModel: QolModelInfo | undefined = undefined,
@@ -151,6 +153,13 @@ export class QolSessionSearchComponent {
151
153
 
152
154
  invalidate(): void {}
153
155
 
156
+ /** Show the index once its load settles; the overlay opens before that. */
157
+ setSessions(sessions: QolSessionSearchLoad): void {
158
+ this.sessions = sessions;
159
+ this.updateResults(false);
160
+ this.tui.requestRender();
161
+ }
162
+
154
163
  private maxOverlayRows(): number {
155
164
  const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
156
165
  const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
@@ -213,8 +222,9 @@ export class QolSessionSearchComponent {
213
222
  }
214
223
 
215
224
  private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
216
- if (scope === "all") return this.sessions;
217
- return this.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
225
+ if (this.sessions.status !== "ready") return [];
226
+ if (scope === "all") return this.sessions.sessions;
227
+ return this.sessions.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
218
228
  }
219
229
 
220
230
  private updateResults(resetSelection = true): void {
@@ -614,7 +624,11 @@ export class QolSessionSearchComponent {
614
624
  if (!compact) lines.push(row(dim(`tokens · re:<pattern> regex · "phrase" exact`)));
615
625
  lines.push(divider());
616
626
 
617
- if (state.results.length === 0) {
627
+ if (this.sessions.status === "loading") {
628
+ lines.push(row(muted("Loading sessions…")));
629
+ } else if (this.sessions.status === "failed") {
630
+ lines.push(row(muted(`Could not load sessions: ${this.sessions.error.split("\n")[0]}`)));
631
+ } else if (state.results.length === 0) {
618
632
  lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
619
633
  } else {
620
634
  lines.push(...this.renderSearchHitPane(inner, row, dim, muted, accent));
@@ -5,9 +5,8 @@ import { settingNumber } from "../settings.js";
5
5
  import { stringifyError } from "../util.js";
6
6
  import {
7
7
  getPendingSessionSearchMessage,
8
- nextSessionSearchPendingActionId,
9
8
  pinSessionModel,
10
- qolSessionSearchPendingActions,
9
+ queueSessionSearchPendingAction,
11
10
  refreshQolSessionSearchCache,
12
11
  sessionDisplayName,
13
12
  setPendingSessionSearchMessage,
@@ -17,12 +16,14 @@ import {
17
16
  buildSessionSearchContextMessageWithLoader,
18
17
  injectSessionSearchContext,
19
18
  } from "./context.js";
20
- import type { QolSessionPaletteAction, QolSessionSearchResult } from "./types.js";
19
+ import type { QolSessionPaletteAction, QolSessionSearchLoad, QolSessionSearchResult } from "./types.js";
21
20
 
22
21
  export {
23
- qolSessionSearchPendingActions,
22
+ clearSessionSearchPendingAction,
24
23
  refreshQolSessionSearchCache,
24
+ releaseQolSessionSearchCache,
25
25
  sessionSearchShortcut,
26
+ takeSessionSearchPendingAction,
26
27
  } from "./cache.js";
27
28
  export { renderSessionSearchContextMessage } from "./context.js";
28
29
 
@@ -31,8 +32,7 @@ function asCommandContext(ctx: ExtensionContext): (ExtensionContext & Partial<Ex
31
32
  }
32
33
 
33
34
  function queueSessionSearchCommandAction(ctx: ExtensionContext, action: QolSessionPaletteAction): void {
34
- const id = nextSessionSearchPendingActionId();
35
- qolSessionSearchPendingActions.set(id, action);
35
+ const id = queueSessionSearchPendingAction(action);
36
36
  ctx.ui.setEditorText(`/search:resume-pending ${id}`);
37
37
  ctx.ui.notify(`${sessionDisplayName(action.result!)} — press enter to ${action.type === "fork" ? "fork" : "resume"}`, "info");
38
38
  }
@@ -121,13 +121,21 @@ export async function openQolSessionSearch(pi: ExtensionAPI, ctx: ExtensionConte
121
121
  ctx.ui.notify("Session search requires interactive UI", "warning");
122
122
  return;
123
123
  }
124
- const sessions = await refreshQolSessionSearchCache(ctx);
124
+ // The overlay opens at once and fills in when the index is loaded: a
125
+ // released index takes a full read of every session file to load again.
126
+ const loading: Promise<QolSessionSearchLoad> = refreshQolSessionSearchCache(ctx).then(
127
+ (sessions) => ({ status: "ready", sessions }),
128
+ (error) => ({ status: "failed", error: stringifyError(error) }),
129
+ );
125
130
  const releaseModalLock = acquirekendexModalLock();
126
131
  let action: QolSessionPaletteAction | undefined;
127
132
  try {
128
133
  const currentModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
129
- action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) =>
130
- new QolSessionSearchComponent(done, tui, theme, sessions, ctx.cwd, initialQuery, currentModel), {
134
+ action = await ctx.ui.custom<QolSessionPaletteAction>((tui, theme, _keybindings, done) => {
135
+ const component = new QolSessionSearchComponent(done, tui, theme, { status: "loading" }, ctx.cwd, initialQuery, currentModel);
136
+ void loading.then((load) => component.setSessions(load));
137
+ return component;
138
+ }, {
131
139
  overlay: true,
132
140
  overlayOptions: {
133
141
  anchor: "center",
@@ -13,6 +13,13 @@ export interface QolSessionSearchSession {
13
13
  path: string;
14
14
  }
15
15
 
16
+ /** The session-search index as the overlay sees it: still loading, loaded,
17
+ * or failed with the load's error. */
18
+ export type QolSessionSearchLoad =
19
+ | { status: "loading" }
20
+ | { status: "ready"; sessions: QolSessionSearchSession[] }
21
+ | { status: "failed"; error: string };
22
+
16
23
  export interface QolSessionSearchResult extends QolSessionSearchSession {
17
24
  rank: number;
18
25
  snippets: string[];
@@ -1,113 +1,18 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
4
1
  import { CONFIG_ID } from "./constants.js";
2
+ import { piSettingsPaths, readPackageConfig, readSettingsFiles } from "./package-config.js";
5
3
 
6
4
  export type kendexConfig = Record<string, unknown>;
7
5
 
8
- export function expandHome(input: string): string {
9
- if (input === "~") return homedir();
10
- if (input.startsWith("~/")) return join(homedir(), input.slice(2));
11
- return input;
12
- }
13
-
14
- export function projectSettingsPath(cwd: string): string {
15
- let current = resolve(cwd);
16
- while (true) {
17
- const candidate = join(current, ".pi", "settings.json");
18
- if (existsSync(candidate)) return candidate;
19
- if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
20
- const parent = dirname(current);
21
- if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
22
- current = parent;
23
- }
24
- }
25
-
26
- const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
27
-
28
- interface ProjectTrustRegistry {
29
- projectSettings?: Map<string, boolean>;
30
- }
31
-
32
- function projectTrustRegistry(): ProjectTrustRegistry {
33
- const host = globalThis as unknown as Record<PropertyKey, ProjectTrustRegistry | undefined>;
34
- const existing = host[PROJECT_TRUST_SYMBOL];
35
- if (existing) return existing;
36
- const created: ProjectTrustRegistry = {};
37
- host[PROJECT_TRUST_SYMBOL] = created;
38
- return created;
39
- }
40
-
41
- export function recordProjectTrust(ctx: { cwd?: string; isProjectTrusted?: () => boolean }): void {
42
- if (!ctx.cwd) return;
43
- let trusted = true;
44
- try {
45
- trusted = ctx.isProjectTrusted?.() === true;
46
- } catch {
47
- trusted = false;
48
- }
49
- const registry = projectTrustRegistry();
50
- if (!registry.projectSettings) registry.projectSettings = new Map();
51
- registry.projectSettings.set(projectSettingsPath(ctx.cwd), trusted);
52
- }
53
-
54
- function projectSettingsTrusted(settingsPath: string): boolean {
55
- return projectTrustRegistry().projectSettings?.get(settingsPath) === true;
56
- }
57
-
58
- /** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
59
- * means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
60
- * where the renderer does not, putting the two on different roots. Hoisted, so
61
- * a circular import cannot reach it inside a temporal dead zone. */
62
- function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
63
-
64
- export function piSettingsPaths(cwd = process.cwd()): string[] {
65
- const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
66
- const userDir = resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
67
- const user = join(userDir, "settings.json");
68
- const project = projectSettingsPath(cwd);
69
- return projectSettingsTrusted(project) ? [user, project] : [user];
70
- }
71
-
72
- function isRecord(value: unknown): value is Record<string, unknown> {
73
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
- }
75
-
76
- /** Each settings file `piSettingsPaths` names that exists and parses, later
77
- * files overriding earlier ones. A malformed file is skipped, as Pi's own
78
- * settings load treats one as empty. */
79
- function readPiSettingsFiles(cwd?: string): Record<string, any>[] {
80
- const files: Record<string, any>[] = [];
81
- for (const settingsPath of piSettingsPaths(cwd)) {
82
- if (!existsSync(settingsPath)) continue;
83
- try {
84
- const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
85
- if (isRecord(parsed)) files.push(parsed);
86
- } catch {
87
- // Ignore a malformed settings file.
88
- }
89
- }
90
- return files;
91
- }
92
-
93
- export function readPackageConfig(packageId: string, cwd?: string): Record<string, unknown> {
94
- const merged: Record<string, unknown> = {};
95
- for (const settings of readPiSettingsFiles(cwd)) {
96
- const config = settings.kendex?.extensionManager?.config?.[packageId];
97
- if (isRecord(config)) Object.assign(merged, config);
98
- }
99
- return merged;
100
- }
101
-
102
6
  /** Pi core's `compaction.enabled` over the files QOL reads its own config
103
7
  * from, resolved as Pi's `SettingsManager.getCompactionEnabled` resolves it:
104
8
  * the last file that sets the key decides, a key no file sets is Pi's default,
105
9
  * true, and any falsy value turns compaction off. */
106
10
  export function piCompactionEnabled(cwd?: string): boolean {
107
11
  let enabled: unknown;
108
- for (const settings of readPiSettingsFiles(cwd)) {
109
- const compaction = settings.compaction;
110
- if (isRecord(compaction) && compaction.enabled !== undefined) enabled = compaction.enabled;
12
+ for (const file of readSettingsFiles(piSettingsPaths(cwd))) {
13
+ if (file.kind !== "parsed") continue;
14
+ const compaction = file.settings.compaction as { enabled?: unknown } | null | undefined;
15
+ if (compaction && typeof compaction === "object" && !Array.isArray(compaction) && compaction.enabled !== undefined) enabled = compaction.enabled;
111
16
  }
112
17
  return Boolean(enabled ?? true);
113
18
  }
package/extensions/qol.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { pruneLanes } from "../scripts/lane-retention.js";
2
4
  import { truncateToWidth, type TUI } from "@earendil-works/pi-tui";
3
5
  import { criticalInfo, lastAssistantTextFromAgentEnd, needsDirection, taskStats } from "./qol/agent-end.js";
4
6
  import { getQuestionService, readCavemanBridge, type QuestionOpenedEventLike } from "./qol/bridges.js";
@@ -16,6 +18,7 @@ import {
16
18
  DEFAULT_IDLE_COMPACTION_SECONDS,
17
19
  DEFAULT_INPUT_BOTTOM_PADDING_LINES,
18
20
  INSTALL_SYMBOL,
21
+ QOL_BUDGET_HANDOFF_FOLDER,
19
22
  QOL_NOTIFICATION_SERVICE_SYMBOL,
20
23
  QUESTION_OPENED_EVENT,
21
24
  SESSION_MANAGER_STATUS_KEY,
@@ -23,6 +26,7 @@ import {
23
26
  SESSION_SEARCH_STATUS_KEY,
24
27
  SESSION_TITLE_SYNC_INTERVAL_MS,
25
28
  STATUS_KEY,
29
+ THINKING_TIMER_MAX_DURATIONS,
26
30
  THINKING_TIMER_STORE_SYMBOL,
27
31
  TMUX_SESSION_TITLE_BORDER_FORMAT,
28
32
  } from "./qol/constants.js";
@@ -39,6 +43,7 @@ import { imageContentForPath, resolveSubmittedImagePaths } from "./qol/images.js
39
43
  import {
40
44
  clearTmuxWindowMark,
41
45
  notifyQuestionOpened,
46
+ resetQolNotificationCooldowns,
42
47
  sendQolNotification,
43
48
  type QolNotificationService,
44
49
  } from "./qol/notifications.js";
@@ -61,15 +66,18 @@ import {
61
66
  } from "./qol/session-rename.js";
62
67
  import { createScheduleController, getScheduleArgumentCompletions } from "./qol/schedule.js";
63
68
  import {
69
+ clearSessionSearchPendingAction,
64
70
  consumePendingSessionSearchContext,
65
71
  openQolSessionSearch,
66
- qolSessionSearchPendingActions,
67
72
  refreshQolSessionSearchCache,
73
+ releaseQolSessionSearchCache,
68
74
  renderSessionSearchContextMessage,
69
75
  runSessionSearchResumeOrFork,
70
76
  sessionSearchShortcut,
77
+ takeSessionSearchPendingAction,
71
78
  } from "./qol/session-search/index.js";
72
- import { recordProjectTrust, settingBoolean, settingNumber, settingString } from "./qol/settings.js";
79
+ import { installSettingsCacheRefresh, piUserDir, recordProjectTrust } from "./qol/package-config.js";
80
+ import { settingBoolean, settingNumber, settingString } from "./qol/settings.js";
73
81
  import { statusMessage } from "./qol/status-message.js";
74
82
  import {
75
83
  formatTmuxSessionTitle,
@@ -248,11 +256,24 @@ export default function qol(pi: ExtensionAPI): void {
248
256
  const duration = Math.max(0, endTimeMs - start);
249
257
  thinkingTimerStore.starts.delete(key);
250
258
  thinkingTimerStore.durations.set(key, duration);
259
+ for (const oldest of thinkingTimerStore.durations.keys()) {
260
+ if (thinkingTimerStore.durations.size <= THINKING_TIMER_MAX_DURATIONS) break;
261
+ thinkingTimerStore.durations.delete(oldest);
262
+ }
251
263
  const label = thinkingTimerStore.labels.get(key);
252
264
  if (label) label.setText(thinkingTimerLabel(thinkingTimerStore.theme, duration, thinkingTimerStore.cwd));
253
265
  if (thinkingTimerStore.starts.size === 0) stopThinkingTimerTicker();
254
266
  };
255
267
 
268
+ // A label is held only to tick it while its block runs. A finished block's
269
+ // label already shows its final time, and a redraw of that message labels
270
+ // it again from `durations`.
271
+ const releaseFinishedThinkingLabels = () => {
272
+ for (const key of thinkingTimerStore.labels.keys()) {
273
+ if (!thinkingTimerStore.starts.has(key)) thinkingTimerStore.labels.delete(key);
274
+ }
275
+ };
276
+
256
277
  const clearIdleCompactionTimer = () => {
257
278
  if (idleCompactionTimer) clearTimeout(idleCompactionTimer);
258
279
  idleCompactionTimer = undefined;
@@ -586,8 +607,13 @@ export default function qol(pi: ExtensionAPI): void {
586
607
  rateLimitAutoResumeController.noteExternalRateLimitEvent(payload, currentCtx);
587
608
  });
588
609
 
610
+ installSettingsCacheRefresh(pi);
589
611
  pi.on("session_start", (event, ctx) => {
590
612
  recordProjectTrust(ctx);
613
+ const pruned = pruneLanes(join(piUserDir(), "kendex", "sessions"), QOL_BUDGET_HANDOFF_FOLDER.split("/"));
614
+ for (const failure of pruned.failed) {
615
+ console.warn(`handoff-prune-error: ${failure.path}\n${failure.error}`);
616
+ }
591
617
  currentCtx = ctx;
592
618
  subscribeCavemanBridge();
593
619
  latestSystemPromptOptions = undefined;
@@ -648,7 +674,8 @@ export default function qol(pi: ExtensionAPI): void {
648
674
  }
649
675
  startQuestionSubscription(ctx);
650
676
  void attemptAutoRename(ctx);
651
- if (settingBoolean("sessionSearch.enabled", true, ctx.cwd)) {
677
+ // A headless session has no search overlay to open, so it loads no index.
678
+ if (ctx.hasUI && settingBoolean("sessionSearch.enabled", true, ctx.cwd)) {
652
679
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
653
680
  sessionSearchWarmupTimer = setTimeout(() => {
654
681
  sessionSearchWarmupTimer = undefined;
@@ -673,8 +700,11 @@ export default function qol(pi: ExtensionAPI): void {
673
700
  clearQuestionSubscribeTimer();
674
701
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
675
702
  sessionSearchWarmupTimer = undefined;
703
+ releaseQolSessionSearchCache();
704
+ clearSessionSearchPendingAction();
676
705
  resetThinkingTimer(undefined);
677
706
  clearTmuxWindowMark(pi);
707
+ resetQolNotificationCooldowns();
678
708
  questionUnsubscribe?.();
679
709
  questionUnsubscribe = undefined;
680
710
  currentCtx = undefined;
@@ -744,6 +774,7 @@ export default function qol(pi: ExtensionAPI): void {
744
774
  }
745
775
  });
746
776
  pi.on("agent_end", (event, ctx) => {
777
+ releaseFinishedThinkingLabels();
747
778
  if (ctx.hasUI) {
748
779
  void refreshStatusline(ctx);
749
780
  requestRender();
@@ -903,13 +934,11 @@ export default function qol(pi: ExtensionAPI): void {
903
934
  pi.registerCommand("search:resume-pending", {
904
935
  description: "Run a pending session-search resume or fork action",
905
936
  handler: async (args, ctx) => {
906
- const id = args.trim();
907
- const action = qolSessionSearchPendingActions.get(id);
937
+ const action = takeSessionSearchPendingAction(args.trim());
908
938
  if (!action) {
909
939
  ctx.ui.notify("No pending session-search resume/fork action found.", "warning");
910
940
  return;
911
941
  }
912
- qolSessionSearchPendingActions.delete(id);
913
942
  if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) ctx.ui.notify("Session resume/fork is unavailable in this context.", "error");
914
943
  },
915
944
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "2.2.1",
3
+ "version": "2.3.2",
4
4
  "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, scheduled prompts, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -504,9 +504,9 @@
504
504
  {
505
505
  "key": "sessionSearch.cacheTtlSeconds",
506
506
  "label": "Session cache TTL",
507
- "description": "Seconds before the in-memory session list is refreshed. Default 0 keeps the cache until you explicitly run /search refresh.",
507
+ "description": "Seconds a loaded session index is kept in memory before it is released; the next search opens at once and reloads it. 0 keeps the index until the session ends.",
508
508
  "type": "number",
509
- "default": 0,
509
+ "default": 300,
510
510
  "category": "Session Search",
511
511
  "apply": "live"
512
512
  },
@@ -988,6 +988,7 @@
988
988
  },
989
989
  "files": [
990
990
  "extensions/",
991
+ "scripts/",
991
992
  "assets/",
992
993
  "README.md",
993
994
  "package.json"