pi-sessions 0.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/extensions/session-ask.ts +297 -0
- package/extensions/session-auto-title/command.ts +109 -0
- package/extensions/session-auto-title/context.ts +41 -0
- package/extensions/session-auto-title/controller.ts +298 -0
- package/extensions/session-auto-title/model.ts +61 -0
- package/extensions/session-auto-title/prompt.ts +54 -0
- package/extensions/session-auto-title/retitle.ts +641 -0
- package/extensions/session-auto-title/state.ts +69 -0
- package/extensions/session-auto-title/wizard.ts +583 -0
- package/extensions/session-auto-title.ts +209 -0
- package/extensions/session-handoff/extract.ts +278 -0
- package/extensions/session-handoff/metadata.ts +96 -0
- package/extensions/session-handoff/picker.ts +394 -0
- package/extensions/session-handoff/query.ts +318 -0
- package/extensions/session-handoff/refs.ts +151 -0
- package/extensions/session-handoff/review.ts +268 -0
- package/extensions/session-handoff.ts +203 -0
- package/extensions/session-hooks.ts +55 -0
- package/extensions/session-index.ts +159 -0
- package/extensions/session-search/extract.ts +997 -0
- package/extensions/session-search/hooks.ts +350 -0
- package/extensions/session-search/normalize.ts +170 -0
- package/extensions/session-search/reindex.ts +93 -0
- package/extensions/session-search.ts +390 -0
- package/extensions/shared/search-snippet.ts +40 -0
- package/extensions/shared/session-index/common.ts +222 -0
- package/extensions/shared/session-index/index.ts +5 -0
- package/extensions/shared/session-index/lineage.ts +417 -0
- package/extensions/shared/session-index/schema.ts +178 -0
- package/extensions/shared/session-index/search.ts +688 -0
- package/extensions/shared/session-index/store.ts +173 -0
- package/extensions/shared/session-ui.ts +15 -0
- package/extensions/shared/settings.ts +141 -0
- package/extensions/shared/time.ts +38 -0
- package/extensions/shared/typebox.ts +61 -0
- package/images/handoff.png +0 -0
- package/images/session-title.png +0 -0
- package/images/session_ask.png +0 -0
- package/images/session_picker.png +0 -0
- package/images/session_search.png +0 -0
- package/package.json +64 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import type { ExtensionContext, KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Focusable,
|
|
4
|
+
Input,
|
|
5
|
+
type KeyId,
|
|
6
|
+
matchesKey,
|
|
7
|
+
type TUI,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
} from "@mariozechner/pi-tui";
|
|
11
|
+
import {
|
|
12
|
+
stripSearchSnippetMarkers,
|
|
13
|
+
transformSearchSnippetMatches,
|
|
14
|
+
} from "../shared/search-snippet.js";
|
|
15
|
+
import { listSessionPickerItems, normalizeDisplayText, type SessionPickerItem } from "./query.js";
|
|
16
|
+
|
|
17
|
+
const MAX_VISIBLE_BROWSE_ROWS = 10;
|
|
18
|
+
const MAX_VISIBLE_SEARCH_ROWS = 4;
|
|
19
|
+
|
|
20
|
+
interface PickerRightColumnWidths {
|
|
21
|
+
marker: number;
|
|
22
|
+
messageCount: number;
|
|
23
|
+
modifiedAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SessionPickerResult =
|
|
27
|
+
| { kind: "cancel" }
|
|
28
|
+
| { kind: "insert-session-token"; sessionId: string };
|
|
29
|
+
|
|
30
|
+
export async function openSessionReferencePicker(
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
indexPath: string,
|
|
33
|
+
shortcut: KeyId,
|
|
34
|
+
): Promise<SessionPickerResult> {
|
|
35
|
+
return ctx.ui.custom<SessionPickerResult>(
|
|
36
|
+
(tui, theme, keybindings, done) =>
|
|
37
|
+
new SessionReferencePickerComponent(tui, theme, keybindings, done, {
|
|
38
|
+
indexPath,
|
|
39
|
+
shortcut,
|
|
40
|
+
getCurrentSessionPath: () => ctx.sessionManager.getSessionFile(),
|
|
41
|
+
getCurrentCwd: () => ctx.cwd,
|
|
42
|
+
}),
|
|
43
|
+
{
|
|
44
|
+
overlay: true,
|
|
45
|
+
overlayOptions: {
|
|
46
|
+
anchor: "bottom-center",
|
|
47
|
+
width: "100%",
|
|
48
|
+
maxHeight: 18,
|
|
49
|
+
margin: { left: 1, right: 1, bottom: 1 },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SessionReferencePickerOptions {
|
|
56
|
+
indexPath: string;
|
|
57
|
+
shortcut: KeyId;
|
|
58
|
+
getCurrentSessionPath: () => string | undefined;
|
|
59
|
+
getCurrentCwd: () => string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class SessionReferencePickerComponent implements Focusable {
|
|
63
|
+
private _focused = false;
|
|
64
|
+
private readonly input = new Input();
|
|
65
|
+
private includeAll = false;
|
|
66
|
+
private items: SessionPickerItem[] = [];
|
|
67
|
+
private selectedIndex = 0;
|
|
68
|
+
|
|
69
|
+
get focused(): boolean {
|
|
70
|
+
return this._focused;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
set focused(value: boolean) {
|
|
74
|
+
this._focused = value;
|
|
75
|
+
this.input.focused = value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
constructor(
|
|
79
|
+
private readonly tui: TUI,
|
|
80
|
+
private readonly theme: Theme,
|
|
81
|
+
private readonly keybindings: KeybindingsManager,
|
|
82
|
+
private readonly done: (result: SessionPickerResult) => void,
|
|
83
|
+
private readonly options: SessionReferencePickerOptions,
|
|
84
|
+
) {
|
|
85
|
+
this.reload();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
invalidate(): void {}
|
|
89
|
+
|
|
90
|
+
handleInput(data: string): void {
|
|
91
|
+
if (matchesKey(data, this.options.shortcut)) {
|
|
92
|
+
this.done({ kind: "cancel" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
97
|
+
this.done({ kind: "cancel" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (this.keybindings.matches(data, "tui.input.tab")) {
|
|
102
|
+
this.includeAll = !this.includeAll;
|
|
103
|
+
this.reload();
|
|
104
|
+
this.tui.requestRender();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (this.keybindings.matches(data, "tui.select.up")) {
|
|
109
|
+
this.moveSelection(-1);
|
|
110
|
+
this.tui.requestRender();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (this.keybindings.matches(data, "tui.select.down")) {
|
|
115
|
+
this.moveSelection(1);
|
|
116
|
+
this.tui.requestRender();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.keybindings.matches(data, "tui.select.pageUp")) {
|
|
121
|
+
this.moveSelection(-this.getMaxVisibleRows());
|
|
122
|
+
this.tui.requestRender();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (this.keybindings.matches(data, "tui.select.pageDown")) {
|
|
127
|
+
this.moveSelection(this.getMaxVisibleRows());
|
|
128
|
+
this.tui.requestRender();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
133
|
+
const selected = this.items[this.selectedIndex];
|
|
134
|
+
if (selected?.kind === "session") {
|
|
135
|
+
this.done({ kind: "insert-session-token", sessionId: selected.sessionId });
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const before = this.input.getValue();
|
|
141
|
+
this.input.handleInput(data);
|
|
142
|
+
if (this.input.getValue() !== before) {
|
|
143
|
+
this.reload();
|
|
144
|
+
this.tui.requestRender();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
render(width: number): string[] {
|
|
149
|
+
const panelWidth = Math.max(0, width);
|
|
150
|
+
const innerWidth = Math.max(0, panelWidth - 2);
|
|
151
|
+
const scopeText = this.includeAll
|
|
152
|
+
? `${this.theme.fg("muted", "○ Current Folder | ")}${this.theme.fg("accent", "◉ All")}`
|
|
153
|
+
: `${this.theme.fg("accent", "◉ Current Folder")}${this.theme.fg("muted", " | ○ All")}`;
|
|
154
|
+
const title = this.theme.bold("Add Session Reference to Prompt");
|
|
155
|
+
const titleWidth = Math.max(0, innerWidth - visibleWidth(scopeText) - 1);
|
|
156
|
+
const titleText = truncateToWidth(title, titleWidth, "…", true);
|
|
157
|
+
const headerSpacing = Math.max(
|
|
158
|
+
0,
|
|
159
|
+
innerWidth - visibleWidth(titleText) - visibleWidth(scopeText),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const lines = [this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)];
|
|
163
|
+
lines.push(this.renderRow(`${titleText}${" ".repeat(headerSpacing)}${scopeText}`, innerWidth));
|
|
164
|
+
lines.push(
|
|
165
|
+
this.renderRow(
|
|
166
|
+
this.theme.fg("muted", "enter add to prompt · esc cancel · tab scope"),
|
|
167
|
+
innerWidth,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
lines.push(this.renderRow("", innerWidth));
|
|
171
|
+
|
|
172
|
+
for (const inputLine of this.input.render(innerWidth)) {
|
|
173
|
+
lines.push(this.renderRow(inputLine, innerWidth));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lines.push(this.renderRow("", innerWidth));
|
|
177
|
+
|
|
178
|
+
const visibleItems = this.getVisibleItems();
|
|
179
|
+
const rightWidths = this.getRightColumnWidths();
|
|
180
|
+
for (const { item, index } of visibleItems) {
|
|
181
|
+
lines.push(
|
|
182
|
+
...this.renderPickerItem(item, index === this.selectedIndex, innerWidth, rightWidths),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (visibleItems.length === 0) {
|
|
187
|
+
lines.push(this.renderRow(this.theme.fg("muted", "No sessions"), innerWidth));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const sessionCount = this.items.filter((item) => item.kind === "session").length;
|
|
191
|
+
if (sessionCount > 0) {
|
|
192
|
+
lines.push(
|
|
193
|
+
this.renderRow(
|
|
194
|
+
this.theme.fg("muted", `(${this.selectedIndex + 1}/${sessionCount})`),
|
|
195
|
+
innerWidth,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private renderPickerItem(
|
|
205
|
+
item: SessionPickerItem,
|
|
206
|
+
selected: boolean,
|
|
207
|
+
innerWidth: number,
|
|
208
|
+
rightWidths: PickerRightColumnWidths,
|
|
209
|
+
): string[] {
|
|
210
|
+
if (item.kind !== "session") {
|
|
211
|
+
const message = [item.title, item.description].filter(Boolean).join(" — ");
|
|
212
|
+
return [
|
|
213
|
+
this.renderRow(
|
|
214
|
+
this.theme.fg(item.kind === "error" ? "error" : "muted", message),
|
|
215
|
+
innerWidth,
|
|
216
|
+
),
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const cursor = selected ? `${this.theme.fg("accent", "›")} ` : " ";
|
|
221
|
+
const right = this.renderRightMetadata(item, rightWidths);
|
|
222
|
+
const highlightedTitle = this.formatTitleHighlight(item.title, item.snippet);
|
|
223
|
+
const leftText = `${item.prefix}${highlightedTitle ?? item.title}`;
|
|
224
|
+
const available = Math.max(8, innerWidth - 2 - visibleWidth(cursor) - visibleWidth(right) - 1);
|
|
225
|
+
const left = truncateToWidth(leftText, available, "…", true);
|
|
226
|
+
const spacing = Math.max(
|
|
227
|
+
1,
|
|
228
|
+
innerWidth - 2 - visibleWidth(cursor) - visibleWidth(left) - visibleWidth(right),
|
|
229
|
+
);
|
|
230
|
+
const titleLine = `${cursor}${left}${" ".repeat(spacing)}${right}`;
|
|
231
|
+
const lines = [this.renderSelectableRow(titleLine, innerWidth, selected)];
|
|
232
|
+
|
|
233
|
+
if (item.snippet && !highlightedTitle) {
|
|
234
|
+
const snippet = this.formatSnippet(
|
|
235
|
+
item.snippet,
|
|
236
|
+
Math.max(8, innerWidth - 2 - visibleWidth(cursor)),
|
|
237
|
+
);
|
|
238
|
+
if (snippet) {
|
|
239
|
+
lines.push(
|
|
240
|
+
this.renderSelectableRow(` ${this.theme.fg("dim", snippet)}`, innerWidth, selected),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private renderRow(content: string, innerWidth: number): string {
|
|
249
|
+
const pad = Math.max(0, innerWidth - visibleWidth(content));
|
|
250
|
+
return `${this.theme.fg("border", "│")}${content}${" ".repeat(pad)}${this.theme.fg("border", "│")}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private renderSelectableRow(content: string, innerWidth: number, selected: boolean): string {
|
|
254
|
+
return this.renderRow(selected ? this.theme.bg("selectedBg", content) : content, innerWidth);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private formatTitleHighlight(title: string, snippet?: string | undefined): string | undefined {
|
|
258
|
+
if (!snippet) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const plainSnippet = normalizeDisplayText(stripSearchSnippetMarkers(snippet));
|
|
263
|
+
if (!plainSnippet || plainSnippet !== normalizeDisplayText(title)) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return this.highlightSnippetMatches(snippet);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private formatSnippet(snippet: string, maxWidth: number): string | undefined {
|
|
271
|
+
const rendered = this.highlightSnippetMatches(snippet);
|
|
272
|
+
if (!rendered) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return truncateToWidth(rendered, maxWidth, "…", true);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private highlightSnippetMatches(snippet: string): string | undefined {
|
|
280
|
+
return transformSearchSnippetMatches(snippet, (match) =>
|
|
281
|
+
this.theme.fg("accent", this.theme.bold(match)),
|
|
282
|
+
)
|
|
283
|
+
?.replace(/\s+/g, " ")
|
|
284
|
+
.trim();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private reload(): void {
|
|
288
|
+
this.items = listSessionPickerItems({
|
|
289
|
+
currentSessionPath: this.options.getCurrentSessionPath(),
|
|
290
|
+
currentCwd: this.options.getCurrentCwd(),
|
|
291
|
+
includeAll: this.includeAll,
|
|
292
|
+
indexPath: this.options.indexPath,
|
|
293
|
+
mode: this.input.getValue().trim() ? "search" : "browse",
|
|
294
|
+
query: this.input.getValue(),
|
|
295
|
+
}).items;
|
|
296
|
+
this.selectedIndex = this.getFirstSessionIndex();
|
|
297
|
+
this.input.focused = this.focused;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private getFirstSessionIndex(): number {
|
|
301
|
+
const firstIndex = this.items.findIndex((item) => item.kind === "session");
|
|
302
|
+
return firstIndex >= 0 ? firstIndex : 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private moveSelection(delta: number): void {
|
|
306
|
+
const sessionIndexes = this.items
|
|
307
|
+
.map((item, index) => (item.kind === "session" ? index : -1))
|
|
308
|
+
.filter((index) => index >= 0);
|
|
309
|
+
if (sessionIndexes.length === 0) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const currentSessionListIndex = Math.max(0, sessionIndexes.indexOf(this.selectedIndex));
|
|
314
|
+
const nextSessionListIndex = Math.max(
|
|
315
|
+
0,
|
|
316
|
+
Math.min(sessionIndexes.length - 1, currentSessionListIndex + delta),
|
|
317
|
+
);
|
|
318
|
+
this.selectedIndex = sessionIndexes[nextSessionListIndex] ?? this.selectedIndex;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private renderRightMetadata(
|
|
322
|
+
item: Extract<SessionPickerItem, { kind: "session" }>,
|
|
323
|
+
widths: PickerRightColumnWidths,
|
|
324
|
+
): string {
|
|
325
|
+
const marker = padEndToWidth(item.marker, widths.marker);
|
|
326
|
+
const messageCount = padStartToWidth(String(item.messageCount), widths.messageCount);
|
|
327
|
+
const modifiedAt = padStartToWidth(item.modifiedAtText ?? "", widths.modifiedAt);
|
|
328
|
+
const plain =
|
|
329
|
+
widths.modifiedAt > 0
|
|
330
|
+
? `${marker} · ${messageCount} ${modifiedAt}`
|
|
331
|
+
: `${marker} · ${messageCount}`;
|
|
332
|
+
return this.theme.fg("dim", plain);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private getRightColumnWidths(): PickerRightColumnWidths {
|
|
336
|
+
const sessionItems = this.items.filter(
|
|
337
|
+
(item): item is Extract<SessionPickerItem, { kind: "session" }> => item.kind === "session",
|
|
338
|
+
);
|
|
339
|
+
return {
|
|
340
|
+
marker: Math.max(0, ...sessionItems.map((item) => visibleWidth(item.marker))),
|
|
341
|
+
messageCount: Math.max(
|
|
342
|
+
0,
|
|
343
|
+
...sessionItems.map((item) => visibleWidth(String(item.messageCount))),
|
|
344
|
+
),
|
|
345
|
+
modifiedAt: Math.max(
|
|
346
|
+
0,
|
|
347
|
+
...sessionItems.map((item) => visibleWidth(item.modifiedAtText ?? "")),
|
|
348
|
+
),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private getVisibleItems(): Array<{ item: SessionPickerItem; index: number }> {
|
|
353
|
+
const maxVisibleRows = this.getMaxVisibleRows();
|
|
354
|
+
if (this.items.length <= maxVisibleRows) {
|
|
355
|
+
return this.items.map((item, index) => ({ item, index }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const sessionIndexes = this.items
|
|
359
|
+
.map((item, index) => (item.kind === "session" ? index : -1))
|
|
360
|
+
.filter((index) => index >= 0);
|
|
361
|
+
const currentSessionListIndex = Math.max(0, sessionIndexes.indexOf(this.selectedIndex));
|
|
362
|
+
const startSessionListIndex = Math.max(
|
|
363
|
+
0,
|
|
364
|
+
Math.min(
|
|
365
|
+
currentSessionListIndex - Math.floor(maxVisibleRows / 2),
|
|
366
|
+
Math.max(0, sessionIndexes.length - maxVisibleRows),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
const endSessionListIndex = Math.min(
|
|
370
|
+
sessionIndexes.length,
|
|
371
|
+
startSessionListIndex + maxVisibleRows,
|
|
372
|
+
);
|
|
373
|
+
const visibleIndexes = new Set(
|
|
374
|
+
sessionIndexes.slice(startSessionListIndex, endSessionListIndex),
|
|
375
|
+
);
|
|
376
|
+
return this.items
|
|
377
|
+
.map((item, index) => ({ item, index }))
|
|
378
|
+
.filter(({ item, index }) => item.kind !== "session" || visibleIndexes.has(index));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private getMaxVisibleRows(): number {
|
|
382
|
+
return this.input.getValue().trim() ? MAX_VISIBLE_SEARCH_ROWS : MAX_VISIBLE_BROWSE_ROWS;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function padStartToWidth(value: string, width: number): string {
|
|
387
|
+
const pad = Math.max(0, width - visibleWidth(value));
|
|
388
|
+
return `${" ".repeat(pad)}${value}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function padEndToWidth(value: string, width: number): string {
|
|
392
|
+
const pad = Math.max(0, width - visibleWidth(value));
|
|
393
|
+
return `${value}${" ".repeat(pad)}`;
|
|
394
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { stripSearchSnippetMarkers } from "../shared/search-snippet.js";
|
|
2
|
+
import {
|
|
3
|
+
getIndexStatus,
|
|
4
|
+
getLineageSessions,
|
|
5
|
+
getSessionByPath,
|
|
6
|
+
INDEX_SCHEMA_VERSION,
|
|
7
|
+
openIndexDatabase,
|
|
8
|
+
type SearchSessionResult,
|
|
9
|
+
type SessionIndexDatabase,
|
|
10
|
+
type SessionLineageRelation,
|
|
11
|
+
searchSessions,
|
|
12
|
+
} from "../shared/session-index/index.js";
|
|
13
|
+
import { shortenSessionId } from "../shared/session-ui.js";
|
|
14
|
+
import { formatCompactRelativeTime } from "../shared/time.js";
|
|
15
|
+
|
|
16
|
+
export const SESSION_TOKEN_PREFIX = "@session:";
|
|
17
|
+
|
|
18
|
+
const MAX_TREE_DEPTH = 3;
|
|
19
|
+
|
|
20
|
+
interface SessionPickerPresentationContext {
|
|
21
|
+
currentSessionId?: string | undefined;
|
|
22
|
+
relationBySessionId: Map<string, SessionLineageRelation>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionPickerSessionItem {
|
|
26
|
+
kind: "session";
|
|
27
|
+
sessionId: string;
|
|
28
|
+
token: string;
|
|
29
|
+
title: string;
|
|
30
|
+
marker: string;
|
|
31
|
+
messageCount: number;
|
|
32
|
+
modifiedAtText?: string | undefined;
|
|
33
|
+
prefix: string;
|
|
34
|
+
snippet?: string | undefined;
|
|
35
|
+
relation?: SessionLineageRelation | "self" | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SessionPickerNoticeItem {
|
|
39
|
+
kind: "error" | "empty";
|
|
40
|
+
title: string;
|
|
41
|
+
description?: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SessionPickerItem = SessionPickerSessionItem | SessionPickerNoticeItem;
|
|
45
|
+
|
|
46
|
+
export interface ListSessionPickerItemsOptions {
|
|
47
|
+
currentSessionPath?: string | undefined;
|
|
48
|
+
currentCwd?: string | undefined;
|
|
49
|
+
includeAll: boolean;
|
|
50
|
+
indexPath: string;
|
|
51
|
+
limit?: number | undefined;
|
|
52
|
+
mode: "browse" | "search";
|
|
53
|
+
query?: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SessionPickerQueryResult {
|
|
57
|
+
items: SessionPickerItem[];
|
|
58
|
+
scopeMode: "default" | "all";
|
|
59
|
+
defaultScopeLabel?: string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface BrowseTreeNode {
|
|
63
|
+
result: SearchSessionResult;
|
|
64
|
+
children: BrowseTreeNode[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listSessionPickerItems(
|
|
68
|
+
options: ListSessionPickerItemsOptions,
|
|
69
|
+
): SessionPickerQueryResult {
|
|
70
|
+
const scopeMode = options.includeAll ? "all" : "default";
|
|
71
|
+
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
|
+
try {
|
|
83
|
+
const currentSession = options.currentSessionPath
|
|
84
|
+
? getSessionByPath(db, options.currentSessionPath)
|
|
85
|
+
: undefined;
|
|
86
|
+
const context = buildPresentationContext(db, currentSession?.sessionId);
|
|
87
|
+
const rankedResults = prioritizeSessionResults(
|
|
88
|
+
searchSessions(
|
|
89
|
+
db,
|
|
90
|
+
{
|
|
91
|
+
cwd: options.includeAll ? undefined : options.currentCwd,
|
|
92
|
+
query: options.mode === "search" ? options.query : undefined,
|
|
93
|
+
limit: options.limit,
|
|
94
|
+
},
|
|
95
|
+
{ defaultLimit: undefined },
|
|
96
|
+
),
|
|
97
|
+
context,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (rankedResults.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
items: [buildEmptyResultItem(options.mode)],
|
|
103
|
+
scopeMode,
|
|
104
|
+
defaultScopeLabel,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sessionItems =
|
|
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();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildPresentationContext(
|
|
124
|
+
db: SessionIndexDatabase,
|
|
125
|
+
currentSessionId?: string | undefined,
|
|
126
|
+
): SessionPickerPresentationContext {
|
|
127
|
+
return {
|
|
128
|
+
currentSessionId,
|
|
129
|
+
relationBySessionId: currentSessionId
|
|
130
|
+
? new Map(
|
|
131
|
+
getLineageSessions(db, currentSessionId).map((row) => [row.sessionId, row.relation]),
|
|
132
|
+
)
|
|
133
|
+
: new Map<string, SessionLineageRelation>(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function prioritizeSessionResults(
|
|
138
|
+
results: SearchSessionResult[],
|
|
139
|
+
context: SessionPickerPresentationContext,
|
|
140
|
+
): SearchSessionResult[] {
|
|
141
|
+
return results
|
|
142
|
+
.map((result, index) => ({ result, index }))
|
|
143
|
+
.sort((a, b) => {
|
|
144
|
+
const priorityDiff =
|
|
145
|
+
getSessionPriority(a.result, context) - getSessionPriority(b.result, context);
|
|
146
|
+
if (priorityDiff !== 0) {
|
|
147
|
+
return priorityDiff;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return a.index - b.index;
|
|
151
|
+
})
|
|
152
|
+
.map(({ result }) => result);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getSessionPriority(
|
|
156
|
+
result: SearchSessionResult,
|
|
157
|
+
context: SessionPickerPresentationContext,
|
|
158
|
+
): number {
|
|
159
|
+
switch (getSessionRelation(result, context)) {
|
|
160
|
+
case "self":
|
|
161
|
+
return 0;
|
|
162
|
+
case "parent":
|
|
163
|
+
return 1;
|
|
164
|
+
case "child":
|
|
165
|
+
return 2;
|
|
166
|
+
case "sibling":
|
|
167
|
+
return 3;
|
|
168
|
+
case "ancestor":
|
|
169
|
+
return 4;
|
|
170
|
+
case "descendant":
|
|
171
|
+
return 5;
|
|
172
|
+
case "ancestor_sibling":
|
|
173
|
+
return 6;
|
|
174
|
+
default:
|
|
175
|
+
return 7;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildBrowseSessionItems(
|
|
180
|
+
results: SearchSessionResult[],
|
|
181
|
+
context: SessionPickerPresentationContext,
|
|
182
|
+
): SessionPickerSessionItem[] {
|
|
183
|
+
const nodesById = new Map<string, BrowseTreeNode>();
|
|
184
|
+
const roots: BrowseTreeNode[] = [];
|
|
185
|
+
|
|
186
|
+
for (const result of results) {
|
|
187
|
+
nodesById.set(result.sessionId, { result, children: [] });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const result of results) {
|
|
191
|
+
const node = nodesById.get(result.sessionId);
|
|
192
|
+
if (!node || !result.parentSessionId) {
|
|
193
|
+
roots.push(node ?? { result, children: [] });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parent = nodesById.get(result.parentSessionId);
|
|
198
|
+
if (!parent) {
|
|
199
|
+
roots.push(node);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
parent.children.push(node);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const items: SessionPickerSessionItem[] = [];
|
|
207
|
+
roots.forEach((root, index) => {
|
|
208
|
+
flattenBrowseTree(items, root, context, 0, index === roots.length - 1);
|
|
209
|
+
});
|
|
210
|
+
return items;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function flattenBrowseTree(
|
|
214
|
+
items: SessionPickerSessionItem[],
|
|
215
|
+
node: BrowseTreeNode,
|
|
216
|
+
context: SessionPickerPresentationContext,
|
|
217
|
+
depth: number,
|
|
218
|
+
isLast: boolean,
|
|
219
|
+
): void {
|
|
220
|
+
items.push(buildSessionItem(node.result, context, buildTreePrefix(depth, isLast)));
|
|
221
|
+
|
|
222
|
+
node.children.forEach((child, index) => {
|
|
223
|
+
flattenBrowseTree(items, child, context, depth + 1, index === node.children.length - 1);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildTreePrefix(depth: number, isLast: boolean): string {
|
|
228
|
+
if (depth <= 0) {
|
|
229
|
+
return "";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const visualDepth = Math.min(depth, MAX_TREE_DEPTH);
|
|
233
|
+
return `${" ".repeat(Math.max(0, visualDepth - 1))}${isLast ? "└─ " : "├─ "}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function buildSessionItem(
|
|
237
|
+
result: SearchSessionResult,
|
|
238
|
+
context: SessionPickerPresentationContext,
|
|
239
|
+
prefix: string = "",
|
|
240
|
+
snippet?: string | undefined,
|
|
241
|
+
): SessionPickerSessionItem {
|
|
242
|
+
return {
|
|
243
|
+
kind: "session",
|
|
244
|
+
sessionId: result.sessionId,
|
|
245
|
+
token: `${SESSION_TOKEN_PREFIX}${result.sessionId}`,
|
|
246
|
+
title: getSessionTitle(result),
|
|
247
|
+
marker: getSessionMarker(result, context),
|
|
248
|
+
messageCount: result.messageCount,
|
|
249
|
+
modifiedAtText: formatCompactRelativeTime(result.modifiedAt),
|
|
250
|
+
prefix,
|
|
251
|
+
snippet,
|
|
252
|
+
relation: getSessionRelation(result, context),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getSessionTitle(result: SearchSessionResult): string {
|
|
257
|
+
return (
|
|
258
|
+
normalizeDisplayText(result.sessionName) ??
|
|
259
|
+
normalizeDisplayText(result.handoffNextTask) ??
|
|
260
|
+
normalizeDisplayText(result.handoffGoal) ??
|
|
261
|
+
normalizeDisplayText(result.firstUserPrompt) ??
|
|
262
|
+
normalizeDisplayText(stripSearchSnippetMarkers(result.snippet)) ??
|
|
263
|
+
shortenSessionId(result.sessionId)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function normalizeDisplayText(value?: string): string | undefined {
|
|
268
|
+
if (!value) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
273
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function getSessionRelation(
|
|
277
|
+
result: SearchSessionResult,
|
|
278
|
+
context: SessionPickerPresentationContext,
|
|
279
|
+
): SessionLineageRelation | "self" | undefined {
|
|
280
|
+
if (context.currentSessionId && result.sessionId === context.currentSessionId) {
|
|
281
|
+
return "self";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return context.relationBySessionId.get(result.sessionId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getSessionMarker(
|
|
288
|
+
result: SearchSessionResult,
|
|
289
|
+
context: SessionPickerPresentationContext,
|
|
290
|
+
): string {
|
|
291
|
+
switch (getSessionRelation(result, context)) {
|
|
292
|
+
case "self":
|
|
293
|
+
return "current";
|
|
294
|
+
case "parent":
|
|
295
|
+
return "parent";
|
|
296
|
+
case "child":
|
|
297
|
+
return "child";
|
|
298
|
+
case "sibling":
|
|
299
|
+
return "sibling";
|
|
300
|
+
default:
|
|
301
|
+
return shortenSessionId(result.sessionId);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildIndexErrorItem(): SessionPickerNoticeItem {
|
|
306
|
+
return {
|
|
307
|
+
kind: "error",
|
|
308
|
+
title: "Session index missing or incompatible",
|
|
309
|
+
description: "Run /session-index to rebuild it.",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function buildEmptyResultItem(mode: "browse" | "search"): SessionPickerNoticeItem {
|
|
314
|
+
return {
|
|
315
|
+
kind: "empty",
|
|
316
|
+
title: mode === "search" ? "No matches" : "No sessions",
|
|
317
|
+
};
|
|
318
|
+
}
|