@vanillagreen/pi-qol 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,273 @@
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { oneLine } from "../ansi.js";
6
+ import {
7
+ DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS,
8
+ DEFAULT_SESSION_SEARCH_SHORTCUT,
9
+ SESSION_SEARCH_PENDING_SYMBOL,
10
+ SESSION_SEARCH_STATUS_KEY,
11
+ } from "../constants.js";
12
+ import { expandHome, piSettingsPaths, settingBoolean, settingNumber, settingString, settingStringAllowEmpty } from "../settings.js";
13
+ import type {
14
+ QolSessionPaletteAction,
15
+ QolSessionSearchPendingMessage,
16
+ QolSessionSearchResult,
17
+ QolSessionSearchScope,
18
+ QolSessionSearchSession,
19
+ QolSessionUserMessage,
20
+ } from "./types.js";
21
+
22
+ let qolSessionSearchCache: QolSessionSearchSession[] = [];
23
+ let qolSessionSearchLoadedAt = 0;
24
+ let qolSessionSearchLoading: Promise<QolSessionSearchSession[]> | undefined;
25
+ const qolSessionUserMessagesCache = new Map<string, QolSessionUserMessage[]>();
26
+ export const qolSessionSearchPendingActions = new Map<string, QolSessionPaletteAction>();
27
+ let qolSessionSearchPendingActionCounter = 0;
28
+
29
+ export function nextSessionSearchPendingActionId(): string {
30
+ return `ss-${Date.now().toString(36)}-${(++qolSessionSearchPendingActionCounter).toString(36)}`;
31
+ }
32
+
33
+ export function getPendingSessionSearchMessage(): QolSessionSearchPendingMessage | undefined {
34
+ return (globalThis as unknown as Record<PropertyKey, unknown>)[SESSION_SEARCH_PENDING_SYMBOL] as QolSessionSearchPendingMessage | undefined;
35
+ }
36
+
37
+ export function setPendingSessionSearchMessage(message: QolSessionSearchPendingMessage | undefined): void {
38
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
39
+ if (message) host[SESSION_SEARCH_PENDING_SYMBOL] = message;
40
+ else delete host[SESSION_SEARCH_PENDING_SYMBOL];
41
+ }
42
+
43
+ function coerceDate(value: unknown): Date {
44
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
45
+ const date = new Date(typeof value === "string" || typeof value === "number" ? value : 0);
46
+ return Number.isNaN(date.getTime()) ? new Date(0) : date;
47
+ }
48
+
49
+ function sessionInfoToSearchSession(info: any): QolSessionSearchSession | undefined {
50
+ if (!info || typeof info.path !== "string") return undefined;
51
+ return {
52
+ allMessagesText: typeof info.allMessagesText === "string" ? info.allMessagesText : "",
53
+ created: coerceDate(info.created),
54
+ cwd: typeof info.cwd === "string" ? info.cwd : "",
55
+ firstMessage: typeof info.firstMessage === "string" ? info.firstMessage : "(no messages)",
56
+ id: typeof info.id === "string" ? info.id : basename(info.path),
57
+ messageCount: Number.isFinite(Number(info.messageCount)) ? Number(info.messageCount) : 0,
58
+ modified: coerceDate(info.modified),
59
+ name: typeof info.name === "string" && info.name.trim() ? info.name.trim() : undefined,
60
+ parentSessionPath: typeof info.parentSessionPath === "string" ? info.parentSessionPath : undefined,
61
+ path: info.path,
62
+ };
63
+ }
64
+
65
+ function resolveSettingsRelativePath(value: string, settingsPath: string): string {
66
+ const expanded = expandHome(value.trim());
67
+ return isAbsolute(expanded) ? expanded : resolve(dirname(settingsPath), expanded);
68
+ }
69
+
70
+ export function sessionSearchShortcut(cwd?: string): string | undefined {
71
+ // Legacy escape hatch from the original ctrl+f setting. If users disabled it,
72
+ // keep shortcuts disabled even though the default shortcut is now conflict-free.
73
+ if (!settingBoolean("sessionSearch.ctrlFShortcut", true, cwd)) return undefined;
74
+ const shortcut = settingStringAllowEmpty("sessionSearch.shortcutKey", DEFAULT_SESSION_SEARCH_SHORTCUT, cwd).trim().toLowerCase();
75
+ if (!shortcut || shortcut === "none" || shortcut === "off" || shortcut === "false") return undefined;
76
+ if (shortcut === "f3") return DEFAULT_SESSION_SEARCH_SHORTCUT;
77
+ return shortcut;
78
+ }
79
+
80
+ function configuredSessionDir(cwd: string): string | undefined {
81
+ const envDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
82
+ if (envDir) return resolveSettingsRelativePath(envDir, join(resolve(cwd), ".pi", "settings.json"));
83
+ let configured: string | undefined;
84
+ for (const settingsPath of piSettingsPaths(cwd)) {
85
+ if (!existsSync(settingsPath)) continue;
86
+ try {
87
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
88
+ if (typeof parsed?.sessionDir === "string" && parsed.sessionDir.trim()) {
89
+ configured = resolveSettingsRelativePath(parsed.sessionDir, settingsPath);
90
+ }
91
+ } catch {
92
+ // Ignore malformed optional settings.
93
+ }
94
+ }
95
+ return configured;
96
+ }
97
+
98
+ function canonicalPathForSessionSearch(path: string | undefined): string | undefined {
99
+ if (!path) return undefined;
100
+ try {
101
+ return realpathSync.native(path);
102
+ } catch {
103
+ return resolve(path);
104
+ }
105
+ }
106
+
107
+ export function sameSessionSearchProject(sessionCwd: string | undefined, cwd: string): boolean {
108
+ return canonicalPathForSessionSearch(sessionCwd) === canonicalPathForSessionSearch(cwd);
109
+ }
110
+
111
+ export function defaultSessionSearchScope(cwd?: string): QolSessionSearchScope {
112
+ return settingString("sessionSearch.defaultScope", "current", cwd).toLowerCase() === "all" ? "all" : "current";
113
+ }
114
+
115
+ async function loadQolSessionSearchSessions(ctx: ExtensionContext, onProgress?: (loaded: number, total: number) => void): Promise<QolSessionSearchSession[]> {
116
+ const customSessionDir = configuredSessionDir(ctx.cwd);
117
+ const infos = customSessionDir
118
+ ? await SessionManager.list(ctx.cwd, customSessionDir, onProgress)
119
+ : await SessionManager.listAll(onProgress);
120
+ return infos.map(sessionInfoToSearchSession).filter((session): session is QolSessionSearchSession => session !== undefined);
121
+ }
122
+
123
+ export async function refreshQolSessionSearchCache(ctx: ExtensionContext, options?: { force?: boolean; quiet?: boolean }): Promise<QolSessionSearchSession[]> {
124
+ const ttlMs = Math.max(0, settingNumber("sessionSearch.cacheTtlSeconds", DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS, ctx.cwd) * 1000);
125
+ const fresh = qolSessionSearchCache.length > 0 && (ttlMs === 0 || Date.now() - qolSessionSearchLoadedAt < ttlMs);
126
+ if (!options?.force && fresh) return qolSessionSearchCache;
127
+ if (qolSessionSearchLoading) return qolSessionSearchLoading;
128
+
129
+ if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, "Loading sessions...");
130
+ qolSessionSearchLoading = loadQolSessionSearchSessions(ctx, (loaded, total) => {
131
+ if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, `Loading sessions ${loaded}/${total}`);
132
+ }).then((sessions) => {
133
+ qolSessionSearchCache = sessions;
134
+ qolSessionSearchLoadedAt = Date.now();
135
+ qolSessionUserMessagesCache.clear();
136
+ return sessions;
137
+ }).finally(() => {
138
+ qolSessionSearchLoading = undefined;
139
+ if (!options?.quiet && ctx.hasUI) ctx.ui.setStatus(SESSION_SEARCH_STATUS_KEY, undefined);
140
+ });
141
+ return qolSessionSearchLoading;
142
+ }
143
+
144
+ function messageContentText(content: unknown): string {
145
+ if (typeof content === "string") return content;
146
+ if (!Array.isArray(content)) return "";
147
+ return content.map((part: any) => {
148
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
149
+ if (part?.type === "image") return "[image]";
150
+ return "";
151
+ }).filter(Boolean).join(" ");
152
+ }
153
+
154
+ function sessionMessageTimestamp(entry: any, message: any): number | undefined {
155
+ if (typeof message?.timestamp === "number" && Number.isFinite(message.timestamp)) return message.timestamp;
156
+ if (typeof entry?.timestamp === "number" && Number.isFinite(entry.timestamp)) return entry.timestamp;
157
+ if (typeof entry?.timestamp === "string") {
158
+ const parsed = new Date(entry.timestamp).getTime();
159
+ if (!Number.isNaN(parsed)) return parsed;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ export function sessionUserMessages(sessionPath: string): QolSessionUserMessage[] {
165
+ const cached = qolSessionUserMessagesCache.get(sessionPath);
166
+ if (cached) return cached;
167
+ const messages: QolSessionUserMessage[] = [];
168
+ try {
169
+ const lines = readFileSync(sessionPath, "utf8").split(/\r?\n/);
170
+ for (const line of lines) {
171
+ if (!line.trim()) continue;
172
+ let entry: any;
173
+ try { entry = JSON.parse(line); } catch { continue; }
174
+ const message = entry?.type === "message" ? entry.message : undefined;
175
+ if (!message || message.role !== "user") continue;
176
+ const text = oneLine(messageContentText(message.content));
177
+ if (!text) continue;
178
+ messages.push({
179
+ entryId: typeof entry.id === "string" ? entry.id : undefined,
180
+ index: messages.length + 1,
181
+ parentId: typeof entry.parentId === "string" || entry.parentId === null ? entry.parentId : undefined,
182
+ text,
183
+ timestamp: sessionMessageTimestamp(entry, message),
184
+ });
185
+ }
186
+ } catch {
187
+ // Ignore unreadable sessions; callers fall back to SessionInfo.firstMessage.
188
+ }
189
+ qolSessionUserMessagesCache.set(sessionPath, messages);
190
+ return messages;
191
+ }
192
+
193
+ export function userMessagesForResult(result: QolSessionSearchResult): QolSessionUserMessage[] {
194
+ const messages = sessionUserMessages(result.path);
195
+ if (messages.length > 0) return messages;
196
+ return [{ index: 1, text: oneLine(result.firstMessage || "No user messages") }];
197
+ }
198
+
199
+ export function sessionUserPromptCount(session: QolSessionSearchSession): number {
200
+ const count = sessionUserMessages(session.path).length;
201
+ if (count > 0) return count;
202
+ return session.firstMessage && session.firstMessage !== "(no messages)" ? 1 : 0;
203
+ }
204
+
205
+ export function promptCountLabel(count: number): string {
206
+ return `${count} prompt${count === 1 ? "" : "s"}`;
207
+ }
208
+
209
+ export function lastUserMessageSnippet(session: QolSessionSearchSession): string {
210
+ const messages = sessionUserMessages(session.path);
211
+ return messages[messages.length - 1]?.text || oneLine(session.firstMessage || "No user messages");
212
+ }
213
+
214
+ export function sessionDisplayName(session: QolSessionSearchSession): string {
215
+ if (session.name) return oneLine(session.name) || "session";
216
+ if (session.cwd) {
217
+ const parts = oneLine(session.cwd).split(/[\\/]+/).filter(Boolean);
218
+ if (parts.length >= 2) return parts.slice(-2).join("/");
219
+ if (parts.length === 1) return parts[0]!;
220
+ }
221
+ return oneLine(basename(session.path) || "session") || "session";
222
+ }
223
+
224
+ export function sessionResumeTitle(session: QolSessionSearchSession): string {
225
+ // Match /resume's primary label: explicit session name, otherwise first user prompt.
226
+ if (session.name) return oneLine(session.name) || sessionDisplayName(session);
227
+ if (session.firstMessage && session.firstMessage !== "(no messages)") return oneLine(session.firstMessage) || sessionDisplayName(session);
228
+ return sessionDisplayName(session);
229
+ }
230
+
231
+ export function shortPathForUi(path: string): string {
232
+ const cleaned = oneLine(path);
233
+ const home = homedir();
234
+ if (cleaned === home) return "~";
235
+ if (cleaned.startsWith(`${home}/`)) return `~${cleaned.slice(home.length)}`;
236
+ return cleaned;
237
+ }
238
+
239
+ export interface QolModelInfo {
240
+ provider: string;
241
+ id: string;
242
+ }
243
+
244
+ export function sessionModelInfo(sessionPath: string): QolModelInfo | undefined {
245
+ try {
246
+ const model = SessionManager.open(sessionPath).buildSessionContext().model;
247
+ if (!model?.provider || !model?.modelId) return undefined;
248
+ return { provider: model.provider, id: model.modelId };
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ }
253
+
254
+ export function sameModel(a: QolModelInfo | undefined, b: QolModelInfo | undefined): boolean {
255
+ return Boolean(a && b && a.provider === b.provider && a.id === b.id);
256
+ }
257
+
258
+ export function modelLabel(model: QolModelInfo | undefined): string {
259
+ return model ? `${model.provider}/${model.id}` : "unknown model";
260
+ }
261
+
262
+ export function pinSessionModel(sessionPath: string, model: NonNullable<ExtensionContext["model"]>, thinkingLevel?: string): void {
263
+ const manager = SessionManager.open(sessionPath);
264
+ const context = manager.buildSessionContext();
265
+ if (context.model?.provider !== model.provider || context.model?.modelId !== model.id) {
266
+ manager.appendModelChange(model.provider, model.id);
267
+ }
268
+ if (thinkingLevel) {
269
+ const branch = manager.getBranch();
270
+ const lastThinking = [...branch].reverse().find((entry: any) => entry?.type === "thinking_level_change") as { thinkingLevel?: string } | undefined;
271
+ if (lastThinking?.thinkingLevel !== thinkingLevel) manager.appendThinkingLevelChange(thinkingLevel as any);
272
+ }
273
+ }