@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.
@@ -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";
@@ -46,6 +51,7 @@ import {
46
51
  installPendingQueueThemePatch,
47
52
  installStatusTextAlignmentPatch,
48
53
  restorePendingQueueThemePatch,
54
+ restoreStatusTextAlignmentPatch,
49
55
  } from "./qol/pending-queue.js";
50
56
  import { permissionGateMatch, permissionGatePrompt } from "./qol/permission-gate.js";
51
57
  import { createRateLimitAutoResumeController, RATE_LIMIT_AUTO_RESUME_EVENT } from "./qol/rate-limit-auto-resume.js";
@@ -60,15 +66,18 @@ import {
60
66
  } from "./qol/session-rename.js";
61
67
  import { createScheduleController, getScheduleArgumentCompletions } from "./qol/schedule.js";
62
68
  import {
69
+ clearSessionSearchPendingAction,
63
70
  consumePendingSessionSearchContext,
64
71
  openQolSessionSearch,
65
- qolSessionSearchPendingActions,
66
72
  refreshQolSessionSearchCache,
73
+ releaseQolSessionSearchCache,
67
74
  renderSessionSearchContextMessage,
68
75
  runSessionSearchResumeOrFork,
69
76
  sessionSearchShortcut,
77
+ takeSessionSearchPendingAction,
70
78
  } from "./qol/session-search/index.js";
71
- 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";
72
81
  import { statusMessage } from "./qol/status-message.js";
73
82
  import {
74
83
  formatTmuxSessionTitle,
@@ -102,7 +111,6 @@ export default function qol(pi: ExtensionAPI): void {
102
111
  if (!settingBoolean("enabled", true)) return;
103
112
 
104
113
  installThinkingTimerPatch();
105
- installStatusTextAlignmentPatch();
106
114
  const thinkingTimerStore: ThinkingTimerStore = {
107
115
  enabled: false,
108
116
  starts: new Map(),
@@ -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;
@@ -381,7 +402,7 @@ export default function qol(pi: ExtensionAPI): void {
381
402
  if (!service) return false;
382
403
  questionUnsubscribe = service.subscribe((event: any) => {
383
404
  if (event?.action !== "opened") return;
384
- notifyQuestionOpened(ctx, { requestId: event.requestId, request: event.request, source: event.source }, "question");
405
+ notifyQuestionOpened(pi, ctx, { requestId: event.requestId, request: event.request, source: event.source }, "question");
385
406
  });
386
407
  return true;
387
408
  };
@@ -570,7 +591,7 @@ export default function qol(pi: ExtensionAPI): void {
570
591
  };
571
592
  const notificationService: QolNotificationService = {
572
593
  notifyQuestionOpened(ctx, event) {
573
- notifyQuestionOpened(ctx ?? currentCtx, event, "question");
594
+ notifyQuestionOpened(pi, ctx ?? currentCtx, event, "question");
574
595
  return true;
575
596
  },
576
597
  };
@@ -579,15 +600,20 @@ export default function qol(pi: ExtensionAPI): void {
579
600
  pi.events.on(QUESTION_OPENED_EVENT, (data: unknown) => {
580
601
  if (!data || typeof data !== "object") return;
581
602
  const event = data as QuestionOpenedEventLike;
582
- notifyQuestionOpened(currentCtx, event, "question");
603
+ notifyQuestionOpened(pi, currentCtx, event, "question");
583
604
  });
584
605
  pi.events.on(RATE_LIMIT_AUTO_RESUME_EVENT, (payload: unknown) => {
585
606
  if (!currentCtx) return;
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;
@@ -601,6 +627,7 @@ export default function qol(pi: ExtensionAPI): void {
601
627
  void consumePendingSessionSearchContext(pi, ctx, event.reason);
602
628
  installAutocompleteHintStyling(ctx);
603
629
  installPendingQueueThemePatch(ctx);
630
+ installStatusTextAlignmentPatch(ctx);
604
631
  if (ctx.hasUI) {
605
632
  ctx.ui.setHiddenThinkingLabel(hiddenThinkingLabel(ctx.ui.theme, ctx.cwd));
606
633
  applyWorkingIndicatorMode(ctx);
@@ -647,7 +674,8 @@ export default function qol(pi: ExtensionAPI): void {
647
674
  }
648
675
  startQuestionSubscription(ctx);
649
676
  void attemptAutoRename(ctx);
650
- 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)) {
651
679
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
652
680
  sessionSearchWarmupTimer = setTimeout(() => {
653
681
  sessionSearchWarmupTimer = undefined;
@@ -672,8 +700,11 @@ export default function qol(pi: ExtensionAPI): void {
672
700
  clearQuestionSubscribeTimer();
673
701
  if (sessionSearchWarmupTimer) clearTimeout(sessionSearchWarmupTimer);
674
702
  sessionSearchWarmupTimer = undefined;
703
+ releaseQolSessionSearchCache();
704
+ clearSessionSearchPendingAction();
675
705
  resetThinkingTimer(undefined);
676
- clearTmuxWindowMark();
706
+ clearTmuxWindowMark(pi);
707
+ resetQolNotificationCooldowns();
677
708
  questionUnsubscribe?.();
678
709
  questionUnsubscribe = undefined;
679
710
  currentCtx = undefined;
@@ -683,6 +714,7 @@ export default function qol(pi: ExtensionAPI): void {
683
714
  ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
684
715
  ctx.ui.setStatus("qol-budget-guard", undefined);
685
716
  restorePendingQueueThemePatch(ctx);
717
+ restoreStatusTextAlignmentPatch();
686
718
  resetStatuslineUi(ctx);
687
719
  ctx.ui.setEditorComponent(undefined);
688
720
  });
@@ -704,7 +736,7 @@ export default function qol(pi: ExtensionAPI): void {
704
736
  });
705
737
  pi.on("agent_start", (_event, ctx) => {
706
738
  clearIdleCompactionTimer();
707
- clearTmuxWindowMark();
739
+ clearTmuxWindowMark(pi);
708
740
  rateLimitAutoResumeController.noteAgentStart(ctx);
709
741
  if (ctx.hasUI) {
710
742
  void refreshStatusline(ctx);
@@ -742,6 +774,7 @@ export default function qol(pi: ExtensionAPI): void {
742
774
  }
743
775
  });
744
776
  pi.on("agent_end", (event, ctx) => {
777
+ releaseFinishedThinkingLabels();
745
778
  if (ctx.hasUI) {
746
779
  void refreshStatusline(ctx);
747
780
  requestRender();
@@ -754,21 +787,21 @@ export default function qol(pi: ExtensionAPI): void {
754
787
  const critical = criticalInfo(text);
755
788
  if (critical) {
756
789
  pendingTaskCompleteNotification = undefined;
757
- sendQolNotification(ctx, "critical", `Critical: ${critical}`, "error", `critical:${critical.slice(0, 80)}`);
790
+ void sendQolNotification(pi, ctx, "critical", `Critical: ${critical}`, "error", `critical:${critical.slice(0, 80)}`);
758
791
  return;
759
792
  }
760
793
  if (pendingTaskCompleteNotification) {
761
794
  const body = pendingTaskCompleteNotification;
762
795
  pendingTaskCompleteNotification = undefined;
763
- sendQolNotification(ctx, "task-complete", body, "info", "task-complete");
796
+ void sendQolNotification(pi, ctx, "task-complete", body, "info", "task-complete");
764
797
  return;
765
798
  }
766
799
  if (ctx.hasPendingMessages?.()) return;
767
800
  if (needsDirection(text)) {
768
- sendQolNotification(ctx, "direction", "Pi is awaiting your direction.", "warning", "direction");
801
+ void sendQolNotification(pi, ctx, "direction", "Pi is awaiting your direction.", "warning", "direction");
769
802
  return;
770
803
  }
771
- sendQolNotification(ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
804
+ void sendQolNotification(pi, ctx, "ready", settingString("notification.readyMessage", "Ready for input", ctx.cwd), "info", "ready");
772
805
  });
773
806
  pi.on("agent_settled", async (_event, ctx) => {
774
807
  await fireStagedBudgetGuard(ctx);
@@ -796,7 +829,7 @@ export default function qol(pi: ExtensionAPI): void {
796
829
  pi.on("session_before_tree", (event, ctx) => handleQolBranchSummary(event, ctx));
797
830
  pi.on("tool_call", async (event: any, ctx) => {
798
831
  if (event?.toolName === "question") {
799
- notifyQuestionOpened(ctx, { requestId: event.input?.id ?? event.toolCallId, request: event.input, source: "tool_call" }, "question");
832
+ notifyQuestionOpened(pi, ctx, { requestId: event.input?.id ?? event.toolCallId, request: event.input, source: "tool_call" }, "question");
800
833
  return undefined;
801
834
  }
802
835
  if (!settingBoolean("permissionGate.enabled", false, ctx.cwd)) return undefined;
@@ -815,7 +848,7 @@ export default function qol(pi: ExtensionAPI): void {
815
848
  });
816
849
 
817
850
  pi.on("input", async (event) => {
818
- clearTmuxWindowMark();
851
+ clearTmuxWindowMark(pi);
819
852
  if (event.source === "extension") return { action: "continue" };
820
853
  const text = event.text ?? "";
821
854
  const paths = currentCtx?.cwd ? resolveSubmittedImagePaths(text, currentCtx.cwd) : [];
@@ -901,13 +934,11 @@ export default function qol(pi: ExtensionAPI): void {
901
934
  pi.registerCommand("search:resume-pending", {
902
935
  description: "Run a pending session-search resume or fork action",
903
936
  handler: async (args, ctx) => {
904
- const id = args.trim();
905
- const action = qolSessionSearchPendingActions.get(id);
937
+ const action = takeSessionSearchPendingAction(args.trim());
906
938
  if (!action) {
907
939
  ctx.ui.notify("No pending session-search resume/fork action found.", "warning");
908
940
  return;
909
941
  }
910
- qolSessionSearchPendingActions.delete(id);
911
942
  if (!(await runSessionSearchResumeOrFork(pi, ctx, action))) ctx.ui.notify("Session resume/fork is unavailable in this context.", "error");
912
943
  },
913
944
  });
@@ -952,7 +983,7 @@ export default function qol(pi: ExtensionAPI): void {
952
983
  return;
953
984
  }
954
985
  if (sub === "notify-test") {
955
- sendQolNotification(ctx, "test", "QOL notification test", "info", `test:${Date.now()}`);
986
+ void sendQolNotification(pi, ctx, "test", "QOL notification test", "info", `test:${Date.now()}`);
956
987
  ctx.ui.notify("Sent QOL notification test.", "info");
957
988
  return;
958
989
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "2.2.0",
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"