pi-sessions 0.6.0 → 0.7.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.
- package/README.md +22 -11
- package/extensions/session-ask/agent.ts +400 -0
- package/extensions/session-ask/navigate.ts +880 -0
- package/extensions/session-ask.ts +79 -131
- package/extensions/session-auto-title/model.ts +3 -11
- package/extensions/session-handoff/picker.ts +50 -4
- package/extensions/session-handoff/query.ts +74 -56
- package/extensions/session-handoff/refs.ts +12 -18
- package/extensions/session-handoff.ts +36 -27
- package/extensions/session-messaging/broker/process.ts +23 -26
- package/extensions/session-messaging/broker/spawn.ts +7 -2
- package/extensions/session-messaging/pi/incoming-runtime.ts +2 -18
- package/extensions/session-messaging/pi/message-contracts.ts +9 -10
- package/extensions/session-messaging/pi/message-view.ts +12 -1
- package/extensions/session-messaging/pi/service.ts +117 -62
- package/extensions/session-messaging/pi/tools.ts +4 -56
- package/extensions/session-search/extract.ts +86 -436
- package/extensions/session-search/hooks.ts +17 -25
- package/extensions/session-search/normalize.ts +1 -1
- package/extensions/session-search/reindex.ts +1 -0
- package/extensions/session-search.ts +157 -128
- package/extensions/shared/model.ts +18 -0
- package/extensions/shared/session-broker/active.ts +26 -0
- package/extensions/{session-messaging/pi → shared/session-broker}/client.ts +16 -19
- package/extensions/{session-messaging/shared → shared/session-broker}/framing.ts +1 -1
- package/extensions/{session-messaging/shared → shared/session-broker}/protocol.ts +5 -12
- package/extensions/shared/session-index/access.ts +128 -0
- package/extensions/shared/session-index/common.ts +43 -48
- package/extensions/shared/session-index/index.ts +1 -0
- package/extensions/shared/session-index/lineage.ts +10 -0
- package/extensions/shared/session-index/query/ast.ts +40 -0
- package/extensions/shared/session-index/query/compiler.ts +146 -0
- package/extensions/shared/session-index/query/lexer.ts +140 -0
- package/extensions/shared/session-index/query/parser.ts +178 -0
- package/extensions/shared/session-index/schema.ts +22 -6
- package/extensions/shared/session-index/scoring.ts +80 -0
- package/extensions/shared/session-index/search.ts +552 -278
- package/extensions/shared/session-index/sqlite.ts +16 -9
- package/extensions/shared/session-index/store.ts +12 -21
- package/extensions/shared/settings.ts +61 -4
- package/extensions/shared/text.ts +50 -0
- package/package.json +1 -1
- /package/extensions/{session-messaging/shared → shared/session-broker}/socket-path.ts +0 -0
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
-
import { complete } from "@earendil-works/pi-ai/compat";
|
|
3
1
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { getKeybindings, type Keybinding, Text } from "@earendil-works/pi-tui";
|
|
5
3
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
4
|
+
import { runSessionAskAgent } from "./session-ask/agent.ts";
|
|
7
5
|
import {
|
|
8
|
-
getIndexStatus,
|
|
9
6
|
getSessionById,
|
|
10
|
-
INDEX_SCHEMA_VERSION,
|
|
11
|
-
openIndexDatabase,
|
|
12
7
|
type SessionLineageRow,
|
|
8
|
+
withSessionIndex,
|
|
13
9
|
} from "./shared/session-index/index.ts";
|
|
14
10
|
import { formatSessionTitleOrShortId, isExactSessionId } from "./shared/session-ui.ts";
|
|
15
11
|
import { loadSettings } from "./shared/settings.ts";
|
|
16
12
|
|
|
17
|
-
const SESSION_ASK_SYSTEM_PROMPT = `You are analyzing a Pi coding session transcript. The transcript includes the entire session tree, including abandoned branches and summaries.
|
|
18
|
-
|
|
19
|
-
Answer the user's question using only the session contents. Be specific and concise. Include exact file paths, decisions, and outcomes when present. If the answer is not in the session, say so clearly.`;
|
|
20
|
-
|
|
21
13
|
const COLLAPSED_ANSWER_PREVIEW_ROWS = 6;
|
|
22
14
|
|
|
23
15
|
interface SessionAskToolParams {
|
|
@@ -25,21 +17,21 @@ interface SessionAskToolParams {
|
|
|
25
17
|
question: string;
|
|
26
18
|
}
|
|
27
19
|
|
|
20
|
+
interface SessionAskRelevantFile {
|
|
21
|
+
path: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
28
25
|
interface SessionAskToolDetails {
|
|
29
|
-
cancelled?: boolean | undefined;
|
|
30
|
-
error?: boolean | undefined;
|
|
31
26
|
answer?: string | undefined;
|
|
27
|
+
debugSessionPath?: string | undefined;
|
|
32
28
|
question?: string | undefined;
|
|
29
|
+
relevantFiles?: SessionAskRelevantFile[] | undefined;
|
|
33
30
|
sessionId?: string | undefined;
|
|
34
31
|
sessionName?: string | undefined;
|
|
35
32
|
sessionPath?: string | undefined;
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
interface TextContentBlock {
|
|
39
|
-
type: "text";
|
|
40
|
-
text: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
35
|
export default function sessionAskExtension(pi: ExtensionAPI): void {
|
|
44
36
|
const settings = loadSettings();
|
|
45
37
|
|
|
@@ -61,32 +53,17 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
|
|
|
61
53
|
async execute(_toolCallId, params: SessionAskToolParams, signal, onUpdate, ctx) {
|
|
62
54
|
const sessionId = params.session.trim();
|
|
63
55
|
if (!sessionId) {
|
|
64
|
-
|
|
56
|
+
throw new Error("session_ask requires a session id.");
|
|
65
57
|
}
|
|
66
58
|
|
|
67
59
|
const question = params.question.trim();
|
|
68
60
|
if (!question) {
|
|
69
|
-
|
|
61
|
+
throw new Error("session_ask requires a question.");
|
|
70
62
|
}
|
|
71
63
|
|
|
72
64
|
const resolvedTarget = resolveSessionAskTarget(sessionId, settings.index.path);
|
|
73
65
|
if (!resolvedTarget.resolved) {
|
|
74
|
-
|
|
75
|
-
error: true,
|
|
76
|
-
sessionId,
|
|
77
|
-
question,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (!ctx.model) {
|
|
82
|
-
return errorResult("No active model is available for session_ask.", { error: true });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
86
|
-
if (!auth.ok || !auth.apiKey) {
|
|
87
|
-
return errorResult(`No API key is available for ${ctx.model.provider}/${ctx.model.id}.`, {
|
|
88
|
-
error: true,
|
|
89
|
-
});
|
|
66
|
+
throw new Error(resolvedTarget.error ?? "Unable to resolve session id.");
|
|
90
67
|
}
|
|
91
68
|
|
|
92
69
|
const progressDetails: SessionAskToolDetails = {
|
|
@@ -100,68 +77,41 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
|
|
|
100
77
|
details: progressDetails,
|
|
101
78
|
});
|
|
102
79
|
|
|
103
|
-
let rendered: RenderedSessionTree;
|
|
104
|
-
try {
|
|
105
|
-
rendered = renderSessionTreeMarkdown(resolvedTarget.resolved.sessionPath);
|
|
106
|
-
} catch (error) {
|
|
107
|
-
return errorResult(formatSessionAskLoadError(resolvedTarget.resolved.sessionPath, error), {
|
|
108
|
-
error: true,
|
|
109
|
-
sessionId,
|
|
110
|
-
sessionPath: resolvedTarget.resolved.sessionPath,
|
|
111
|
-
question,
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const loadedDetails: SessionAskToolDetails = {
|
|
116
|
-
question,
|
|
117
|
-
sessionId: rendered.sessionId,
|
|
118
|
-
sessionName: rendered.sessionName,
|
|
119
|
-
sessionPath: resolvedTarget.resolved.sessionPath,
|
|
120
|
-
};
|
|
121
80
|
onUpdate?.({
|
|
122
81
|
content: [
|
|
123
82
|
{
|
|
124
83
|
type: "text",
|
|
125
|
-
text: formatSessionAskHeader(
|
|
84
|
+
text: formatSessionAskHeader(
|
|
85
|
+
resolvedTarget.resolved.sessionId,
|
|
86
|
+
resolvedTarget.resolved.sessionName,
|
|
87
|
+
question,
|
|
88
|
+
),
|
|
126
89
|
},
|
|
127
90
|
],
|
|
128
|
-
details:
|
|
91
|
+
details: progressDetails,
|
|
129
92
|
});
|
|
130
93
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const response = await complete(
|
|
143
|
-
ctx.model,
|
|
144
|
-
{ systemPrompt: SESSION_ASK_SYSTEM_PROMPT, messages: [userMessage] },
|
|
145
|
-
signal
|
|
146
|
-
? {
|
|
147
|
-
apiKey: auth.apiKey,
|
|
148
|
-
...(auth.headers ? { headers: auth.headers } : {}),
|
|
149
|
-
signal,
|
|
150
|
-
}
|
|
151
|
-
: {
|
|
152
|
-
apiKey: auth.apiKey,
|
|
153
|
-
...(auth.headers ? { headers: auth.headers } : {}),
|
|
154
|
-
},
|
|
155
|
-
);
|
|
94
|
+
const agentResult = await runSessionAskAgent({
|
|
95
|
+
ctx,
|
|
96
|
+
target: resolvedTarget.resolved,
|
|
97
|
+
question,
|
|
98
|
+
indexPath: settings.index.path,
|
|
99
|
+
askSettings: settings.ask,
|
|
100
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
101
|
+
signal,
|
|
102
|
+
});
|
|
156
103
|
|
|
157
|
-
if (
|
|
158
|
-
|
|
104
|
+
if (signal?.aborted) {
|
|
105
|
+
throw new Error("Session ask was cancelled.");
|
|
159
106
|
}
|
|
160
107
|
|
|
161
|
-
const answer =
|
|
108
|
+
const answer = agentResult?.answer || "Could not determine an answer from the session.";
|
|
109
|
+
const relevantFiles = agentResult?.relevantFiles ?? [];
|
|
162
110
|
|
|
163
111
|
const details: SessionAskToolDetails = {
|
|
164
112
|
answer,
|
|
113
|
+
debugSessionPath: agentResult?.debugSessionPath,
|
|
114
|
+
relevantFiles,
|
|
165
115
|
sessionId: resolvedTarget.resolved.sessionId,
|
|
166
116
|
sessionName: resolvedTarget.resolved.sessionName,
|
|
167
117
|
sessionPath: resolvedTarget.resolved.sessionPath,
|
|
@@ -172,21 +122,29 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
|
|
|
172
122
|
{
|
|
173
123
|
type: "text",
|
|
174
124
|
text: [
|
|
175
|
-
formatSessionAskHeader(
|
|
176
|
-
|
|
125
|
+
formatSessionAskHeader(
|
|
126
|
+
resolvedTarget.resolved.sessionId,
|
|
127
|
+
resolvedTarget.resolved.sessionName,
|
|
128
|
+
question,
|
|
129
|
+
),
|
|
130
|
+
formatSessionAskAnswer(answer, relevantFiles, agentResult?.debugSessionPath),
|
|
177
131
|
].join("\n\n"),
|
|
178
132
|
},
|
|
179
133
|
],
|
|
180
134
|
details,
|
|
181
135
|
};
|
|
182
136
|
},
|
|
183
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
137
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
184
138
|
const details = result.details as SessionAskToolDetails | undefined;
|
|
185
139
|
const content = result.content[0];
|
|
186
140
|
if (content?.type !== "text") {
|
|
187
141
|
return new Text(theme.fg("error", "No session output"), 0, 0);
|
|
188
142
|
}
|
|
189
143
|
|
|
144
|
+
if (context.isError) {
|
|
145
|
+
return new Text(theme.fg("error", content.text), 0, 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
190
148
|
if (isPartial) {
|
|
191
149
|
const lines = [theme.bold(theme.fg("warning", "Reading session..."))];
|
|
192
150
|
if (details?.sessionId || details?.sessionName) {
|
|
@@ -199,14 +157,6 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
|
|
|
199
157
|
return new Text(lines.join("\n"), 0, 0);
|
|
200
158
|
}
|
|
201
159
|
|
|
202
|
-
if (details?.cancelled) {
|
|
203
|
-
return new Text(theme.fg("warning", content.text), 0, 0);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (details?.error) {
|
|
207
|
-
return new Text(theme.fg("error", content.text), 0, 0);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
160
|
const answer = (details?.answer ?? "").trim() || "No answer generated.";
|
|
211
161
|
const identity = formatSessionTitleOrShortId(details?.sessionName, details?.sessionId);
|
|
212
162
|
const lines = [`title: ${theme.bold(identity)}`];
|
|
@@ -234,30 +184,20 @@ function resolveSessionAskTarget(
|
|
|
234
184
|
};
|
|
235
185
|
}
|
|
236
186
|
|
|
237
|
-
const status = getIndexStatus(indexPath);
|
|
238
|
-
if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
239
|
-
return {
|
|
240
|
-
error: `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const db = openIndexDatabase(status.dbPath, { create: false });
|
|
245
187
|
try {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
188
|
+
return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db }) => {
|
|
189
|
+
const row = getSessionById(db, sessionId);
|
|
190
|
+
if (!row) {
|
|
191
|
+
return { error: `No indexed session found for id: ${sessionId}` };
|
|
192
|
+
}
|
|
250
193
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
194
|
+
return { resolved: row };
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
254
198
|
}
|
|
255
199
|
}
|
|
256
200
|
|
|
257
|
-
function collectTextBlocks(content: Array<{ type: string; text?: string }>): string[] {
|
|
258
|
-
return content.filter(isTextContentBlock).map((block) => block.text);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
201
|
function formatSessionAskHeader(sessionId: string, sessionName: string, question: string): string {
|
|
262
202
|
return [
|
|
263
203
|
`session: ${sessionId}`,
|
|
@@ -266,14 +206,20 @@ function formatSessionAskHeader(sessionId: string, sessionName: string, question
|
|
|
266
206
|
].join("\n");
|
|
267
207
|
}
|
|
268
208
|
|
|
269
|
-
function
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
209
|
+
function formatSessionAskAnswer(
|
|
210
|
+
answer: string,
|
|
211
|
+
relevantFiles: SessionAskRelevantFile[],
|
|
212
|
+
debugSessionPath: string | undefined,
|
|
213
|
+
): string {
|
|
214
|
+
const lines = [answer || "No answer generated."];
|
|
215
|
+
if (relevantFiles.length > 0) {
|
|
216
|
+
lines.push("", "Relevant files:");
|
|
217
|
+
lines.push(...relevantFiles.map((file) => `- ${file.path}: ${file.reason}`));
|
|
218
|
+
}
|
|
219
|
+
if (debugSessionPath) {
|
|
220
|
+
lines.push("", `debug_session: ${debugSessionPath}`);
|
|
221
|
+
}
|
|
222
|
+
return lines.join("\n");
|
|
277
223
|
}
|
|
278
224
|
|
|
279
225
|
function formatSessionAskAnswerPreview(answer: string, expanded: boolean, theme: Theme): string[] {
|
|
@@ -282,17 +228,19 @@ function formatSessionAskAnswerPreview(answer: string, expanded: boolean, theme:
|
|
|
282
228
|
return lines;
|
|
283
229
|
}
|
|
284
230
|
|
|
285
|
-
return [
|
|
231
|
+
return [
|
|
232
|
+
...lines.slice(0, COLLAPSED_ANSWER_PREVIEW_ROWS),
|
|
233
|
+
formatOverflowHint(lines.length - COLLAPSED_ANSWER_PREVIEW_ROWS, lines.length, theme),
|
|
234
|
+
];
|
|
286
235
|
}
|
|
287
236
|
|
|
288
|
-
function
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return `Unable to load session file: ${sessionPath}`;
|
|
237
|
+
function formatOverflowHint(remaining: number, total: number, theme: Theme): string {
|
|
238
|
+
return `${theme.fg("muted", `... (${remaining} more lines, ${total} total,`)} ${theme.fg(
|
|
239
|
+
"dim",
|
|
240
|
+
formatKeyHint("app.tools.expand"),
|
|
241
|
+
)}${theme.fg("muted", " to expand)")}`;
|
|
294
242
|
}
|
|
295
243
|
|
|
296
|
-
function
|
|
297
|
-
return
|
|
244
|
+
function formatKeyHint(keybinding: Keybinding): string {
|
|
245
|
+
return getKeybindings().getKeys(keybinding).join("/");
|
|
298
246
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { findModelByReference } from "../shared/model.ts";
|
|
3
4
|
import { ModelReference } from "../shared/settings.ts";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_AUTO_TITLE_FALLBACK_MODELS: readonly ModelReference[] = [
|
|
@@ -22,7 +23,7 @@ export function resolveAutoTitleModel(
|
|
|
22
23
|
const availableModels = ctx.modelRegistry.getAvailable();
|
|
23
24
|
|
|
24
25
|
if (configuredModel) {
|
|
25
|
-
const configuredMatch =
|
|
26
|
+
const configuredMatch = findModelByReference(availableModels, configuredModel);
|
|
26
27
|
if (configuredMatch) {
|
|
27
28
|
return {
|
|
28
29
|
model: configuredMatch,
|
|
@@ -32,7 +33,7 @@ export function resolveAutoTitleModel(
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
for (const fallbackReference of DEFAULT_AUTO_TITLE_FALLBACK_MODELS) {
|
|
35
|
-
const fallbackMatch =
|
|
36
|
+
const fallbackMatch = findModelByReference(availableModels, fallbackReference);
|
|
36
37
|
if (fallbackMatch) {
|
|
37
38
|
return {
|
|
38
39
|
model: fallbackMatch,
|
|
@@ -50,12 +51,3 @@ export function resolveAutoTitleModel(
|
|
|
50
51
|
source: "current",
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
-
function findMatchingModel(
|
|
55
|
-
availableModels: Model<Api>[],
|
|
56
|
-
reference: ModelReference,
|
|
57
|
-
): Model<Api> | undefined {
|
|
58
|
-
return availableModels.find(
|
|
59
|
-
(model) => model.provider === reference.provider && model.id === reference.modelId,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
@@ -16,6 +16,7 @@ import { listSessionPickerItems, normalizeDisplayText, type SessionPickerItem }
|
|
|
16
16
|
|
|
17
17
|
const MAX_VISIBLE_BROWSE_ROWS = 10;
|
|
18
18
|
const MAX_VISIBLE_SEARCH_ROWS = 4;
|
|
19
|
+
const SEARCH_RELOAD_DEBOUNCE_MS = 200;
|
|
19
20
|
|
|
20
21
|
interface PickerRightColumnWidths {
|
|
21
22
|
marker: number;
|
|
@@ -65,6 +66,7 @@ export class SessionReferencePickerComponent implements Focusable {
|
|
|
65
66
|
private includeAll = false;
|
|
66
67
|
private items: SessionPickerItem[] = [];
|
|
67
68
|
private selectedIndex = 0;
|
|
69
|
+
private searchReloadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
68
70
|
|
|
69
71
|
get focused(): boolean {
|
|
70
72
|
return this._focused;
|
|
@@ -89,17 +91,18 @@ export class SessionReferencePickerComponent implements Focusable {
|
|
|
89
91
|
|
|
90
92
|
handleInput(data: string): void {
|
|
91
93
|
if (matchesKey(data, this.options.shortcut)) {
|
|
92
|
-
this.
|
|
94
|
+
this.finish({ kind: "cancel" });
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
97
|
-
this.
|
|
99
|
+
this.finish({ kind: "cancel" });
|
|
98
100
|
return;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
if (this.keybindings.matches(data, "tui.input.tab")) {
|
|
102
104
|
this.includeAll = !this.includeAll;
|
|
105
|
+
this.cancelSearchReload();
|
|
103
106
|
this.reload();
|
|
104
107
|
this.tui.requestRender();
|
|
105
108
|
return;
|
|
@@ -130,9 +133,10 @@ export class SessionReferencePickerComponent implements Focusable {
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
136
|
+
this.flushSearchReload();
|
|
133
137
|
const selected = this.items[this.selectedIndex];
|
|
134
138
|
if (selected?.kind === "session") {
|
|
135
|
-
this.
|
|
139
|
+
this.finish({ kind: "insert-session-token", sessionId: selected.sessionId });
|
|
136
140
|
}
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
@@ -140,7 +144,7 @@ export class SessionReferencePickerComponent implements Focusable {
|
|
|
140
144
|
const before = this.input.getValue();
|
|
141
145
|
this.input.handleInput(data);
|
|
142
146
|
if (this.input.getValue() !== before) {
|
|
143
|
-
this.
|
|
147
|
+
this.reloadAfterInputChange();
|
|
144
148
|
this.tui.requestRender();
|
|
145
149
|
}
|
|
146
150
|
}
|
|
@@ -284,6 +288,48 @@ export class SessionReferencePickerComponent implements Focusable {
|
|
|
284
288
|
.trim();
|
|
285
289
|
}
|
|
286
290
|
|
|
291
|
+
private reloadAfterInputChange(): void {
|
|
292
|
+
if (!this.input.getValue().trim()) {
|
|
293
|
+
this.cancelSearchReload();
|
|
294
|
+
this.reload();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.scheduleSearchReload();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private scheduleSearchReload(): void {
|
|
302
|
+
this.cancelSearchReload();
|
|
303
|
+
this.searchReloadTimer = setTimeout(() => {
|
|
304
|
+
this.searchReloadTimer = undefined;
|
|
305
|
+
this.reload();
|
|
306
|
+
this.tui.requestRender();
|
|
307
|
+
}, SEARCH_RELOAD_DEBOUNCE_MS);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private flushSearchReload(): void {
|
|
311
|
+
if (!this.searchReloadTimer) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
this.cancelSearchReload();
|
|
316
|
+
this.reload();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private cancelSearchReload(): void {
|
|
320
|
+
if (!this.searchReloadTimer) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
clearTimeout(this.searchReloadTimer);
|
|
325
|
+
this.searchReloadTimer = undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private finish(result: SessionPickerResult): void {
|
|
329
|
+
this.cancelSearchReload();
|
|
330
|
+
this.done(result);
|
|
331
|
+
}
|
|
332
|
+
|
|
287
333
|
private reload(): void {
|
|
288
334
|
this.items = listSessionPickerItems({
|
|
289
335
|
currentSessionPath: this.options.getCurrentSessionPath(),
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
|
|
2
2
|
import {
|
|
3
|
-
getIndexStatus,
|
|
4
|
-
getLineageRelationMap,
|
|
5
3
|
getSessionByPath,
|
|
6
|
-
INDEX_SCHEMA_VERSION,
|
|
7
|
-
openIndexDatabase,
|
|
8
4
|
type SearchSessionResult,
|
|
9
5
|
type SessionIndexDatabase,
|
|
10
6
|
type SessionLineageRelation,
|
|
11
7
|
searchSessions,
|
|
8
|
+
withSessionIndex,
|
|
12
9
|
} from "../shared/session-index/index.ts";
|
|
10
|
+
import { SearchQuerySyntaxError } from "../shared/session-index/query/ast.ts";
|
|
13
11
|
import { shortenSessionId } from "../shared/session-ui.ts";
|
|
14
12
|
import { formatCompactRelativeTime } from "../shared/time.ts";
|
|
15
13
|
|
|
@@ -19,7 +17,6 @@ const MAX_TREE_DEPTH = 3;
|
|
|
19
17
|
|
|
20
18
|
interface SessionPickerPresentationContext {
|
|
21
19
|
currentSessionId?: string | undefined;
|
|
22
|
-
relationBySessionId: Map<string, SessionLineageRelation>;
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
export interface SessionPickerSessionItem {
|
|
@@ -32,7 +29,7 @@ export interface SessionPickerSessionItem {
|
|
|
32
29
|
modifiedAtText?: string | undefined;
|
|
33
30
|
prefix: string;
|
|
34
31
|
snippet?: string | undefined;
|
|
35
|
-
relation?: SessionLineageRelation |
|
|
32
|
+
relation?: SessionLineageRelation | undefined;
|
|
36
33
|
}
|
|
37
34
|
|
|
38
35
|
export interface SessionPickerNoticeItem {
|
|
@@ -69,67 +66,75 @@ export function listSessionPickerItems(
|
|
|
69
66
|
): SessionPickerQueryResult {
|
|
70
67
|
const scopeMode = options.includeAll ? "all" : "default";
|
|
71
68
|
const defaultScopeLabel = options.currentCwd ? "current folder" : undefined;
|
|
72
|
-
const status = getIndexStatus(options.indexPath);
|
|
73
|
-
if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
74
|
-
return {
|
|
75
|
-
items: [buildIndexErrorItem()],
|
|
76
|
-
scopeMode,
|
|
77
|
-
defaultScopeLabel,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const db = openIndexDatabase(status.dbPath, { create: false });
|
|
82
69
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
db,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
70
|
+
return (
|
|
71
|
+
withSessionIndex(options.indexPath, { mode: "read", required: false }, ({ db }) => {
|
|
72
|
+
const currentSession = options.currentSessionPath
|
|
73
|
+
? getSessionByPath(db, options.currentSessionPath)
|
|
74
|
+
: undefined;
|
|
75
|
+
const context = buildPresentationContext(currentSession?.sessionId);
|
|
76
|
+
const searchResults = searchSessionsSafely(db, options, currentSession?.sessionId);
|
|
77
|
+
const rankedResults =
|
|
78
|
+
options.mode === "browse"
|
|
79
|
+
? prioritizeSessionResults(searchResults, context)
|
|
80
|
+
: searchResults;
|
|
81
|
+
|
|
82
|
+
if (rankedResults.length === 0) {
|
|
83
|
+
return {
|
|
84
|
+
items: [buildEmptyResultItem(options.mode)],
|
|
85
|
+
scopeMode,
|
|
86
|
+
defaultScopeLabel,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sessionItems =
|
|
91
|
+
options.mode === "browse"
|
|
92
|
+
? buildBrowseSessionItems(rankedResults, context)
|
|
93
|
+
: rankedResults.map((result) =>
|
|
94
|
+
buildSessionItem(result, context, "", getBestTextSnippet(result)),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
items: sessionItems,
|
|
99
|
+
scopeMode,
|
|
100
|
+
defaultScopeLabel,
|
|
101
|
+
};
|
|
102
|
+
}) ?? {
|
|
103
|
+
items: [buildIndexErrorItem()],
|
|
104
|
+
scopeMode,
|
|
105
|
+
defaultScopeLabel,
|
|
106
|
+
}
|
|
98
107
|
);
|
|
99
|
-
|
|
100
|
-
if (
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof SearchQuerySyntaxError) {
|
|
101
110
|
return {
|
|
102
|
-
items: [
|
|
111
|
+
items: [buildSearchSyntaxErrorItem(error)],
|
|
103
112
|
scopeMode,
|
|
104
113
|
defaultScopeLabel,
|
|
105
114
|
};
|
|
106
115
|
}
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
options.mode === "browse"
|
|
110
|
-
? buildBrowseSessionItems(rankedResults, context)
|
|
111
|
-
: rankedResults.map((result) => buildSessionItem(result, context, "", result.snippet));
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
items: sessionItems,
|
|
115
|
-
scopeMode,
|
|
116
|
-
defaultScopeLabel,
|
|
117
|
-
};
|
|
118
|
-
} finally {
|
|
119
|
-
db.close();
|
|
117
|
+
throw error;
|
|
120
118
|
}
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
function
|
|
121
|
+
function searchSessionsSafely(
|
|
124
122
|
db: SessionIndexDatabase,
|
|
123
|
+
options: ListSessionPickerItemsOptions,
|
|
124
|
+
currentSessionId?: string | undefined,
|
|
125
|
+
): SearchSessionResult[] {
|
|
126
|
+
return searchSessions(db, {
|
|
127
|
+
cwd: options.includeAll ? undefined : options.currentCwd,
|
|
128
|
+
query: options.mode === "search" ? options.query : undefined,
|
|
129
|
+
limit: options.limit,
|
|
130
|
+
relativeToSessionId: currentSessionId,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildPresentationContext(
|
|
125
135
|
currentSessionId?: string | undefined,
|
|
126
136
|
): SessionPickerPresentationContext {
|
|
127
|
-
return {
|
|
128
|
-
currentSessionId,
|
|
129
|
-
relationBySessionId: currentSessionId
|
|
130
|
-
? getLineageRelationMap(db, currentSessionId)
|
|
131
|
-
: new Map<string, SessionLineageRelation>(),
|
|
132
|
-
};
|
|
137
|
+
return { currentSessionId };
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
function prioritizeSessionResults(
|
|
@@ -251,13 +256,18 @@ function buildSessionItem(
|
|
|
251
256
|
};
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
function getBestTextSnippet(result: SearchSessionResult): string | undefined {
|
|
260
|
+
const textEvidence = result.evidence.find((evidence) => evidence.kind === "text");
|
|
261
|
+
return textEvidence?.kind === "text" ? textEvidence.snippet : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
254
264
|
function getSessionTitle(result: SearchSessionResult): string {
|
|
255
265
|
return (
|
|
256
266
|
normalizeDisplayText(result.sessionName) ??
|
|
257
267
|
normalizeDisplayText(result.handoffNextTask) ??
|
|
258
268
|
normalizeDisplayText(result.handoffGoal) ??
|
|
259
269
|
normalizeDisplayText(result.firstUserPrompt) ??
|
|
260
|
-
normalizeDisplayText(stripSearchSnippetMarkers(result
|
|
270
|
+
normalizeDisplayText(stripSearchSnippetMarkers(getBestTextSnippet(result))) ??
|
|
261
271
|
shortenSessionId(result.sessionId)
|
|
262
272
|
);
|
|
263
273
|
}
|
|
@@ -274,12 +284,12 @@ export function normalizeDisplayText(value?: string): string | undefined {
|
|
|
274
284
|
function getSessionRelation(
|
|
275
285
|
result: SearchSessionResult,
|
|
276
286
|
context: SessionPickerPresentationContext,
|
|
277
|
-
): SessionLineageRelation |
|
|
287
|
+
): SessionLineageRelation | undefined {
|
|
278
288
|
if (context.currentSessionId && result.sessionId === context.currentSessionId) {
|
|
279
289
|
return "self";
|
|
280
290
|
}
|
|
281
291
|
|
|
282
|
-
return
|
|
292
|
+
return result.relation;
|
|
283
293
|
}
|
|
284
294
|
|
|
285
295
|
function getSessionMarker(
|
|
@@ -308,6 +318,14 @@ function buildIndexErrorItem(): SessionPickerNoticeItem {
|
|
|
308
318
|
};
|
|
309
319
|
}
|
|
310
320
|
|
|
321
|
+
function buildSearchSyntaxErrorItem(error: SearchQuerySyntaxError): SessionPickerNoticeItem {
|
|
322
|
+
return {
|
|
323
|
+
kind: "error",
|
|
324
|
+
title: "Invalid search query",
|
|
325
|
+
description: error.message,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
311
329
|
function buildEmptyResultItem(mode: "browse" | "search"): SessionPickerNoticeItem {
|
|
312
330
|
return {
|
|
313
331
|
kind: "empty",
|