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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/extensions/session-ask.ts +297 -0
  4. package/extensions/session-auto-title/command.ts +109 -0
  5. package/extensions/session-auto-title/context.ts +41 -0
  6. package/extensions/session-auto-title/controller.ts +298 -0
  7. package/extensions/session-auto-title/model.ts +61 -0
  8. package/extensions/session-auto-title/prompt.ts +54 -0
  9. package/extensions/session-auto-title/retitle.ts +641 -0
  10. package/extensions/session-auto-title/state.ts +69 -0
  11. package/extensions/session-auto-title/wizard.ts +583 -0
  12. package/extensions/session-auto-title.ts +209 -0
  13. package/extensions/session-handoff/extract.ts +278 -0
  14. package/extensions/session-handoff/metadata.ts +96 -0
  15. package/extensions/session-handoff/picker.ts +394 -0
  16. package/extensions/session-handoff/query.ts +318 -0
  17. package/extensions/session-handoff/refs.ts +151 -0
  18. package/extensions/session-handoff/review.ts +268 -0
  19. package/extensions/session-handoff.ts +203 -0
  20. package/extensions/session-hooks.ts +55 -0
  21. package/extensions/session-index.ts +159 -0
  22. package/extensions/session-search/extract.ts +997 -0
  23. package/extensions/session-search/hooks.ts +350 -0
  24. package/extensions/session-search/normalize.ts +170 -0
  25. package/extensions/session-search/reindex.ts +93 -0
  26. package/extensions/session-search.ts +390 -0
  27. package/extensions/shared/search-snippet.ts +40 -0
  28. package/extensions/shared/session-index/common.ts +222 -0
  29. package/extensions/shared/session-index/index.ts +5 -0
  30. package/extensions/shared/session-index/lineage.ts +417 -0
  31. package/extensions/shared/session-index/schema.ts +178 -0
  32. package/extensions/shared/session-index/search.ts +688 -0
  33. package/extensions/shared/session-index/store.ts +173 -0
  34. package/extensions/shared/session-ui.ts +15 -0
  35. package/extensions/shared/settings.ts +141 -0
  36. package/extensions/shared/time.ts +38 -0
  37. package/extensions/shared/typebox.ts +61 -0
  38. package/images/handoff.png +0 -0
  39. package/images/session-title.png +0 -0
  40. package/images/session_ask.png +0 -0
  41. package/images/session_picker.png +0 -0
  42. package/images/session_search.png +0 -0
  43. package/package.json +64 -0
@@ -0,0 +1,583 @@
1
+ import type { Api, Model } from "@mariozechner/pi-ai";
2
+ import {
3
+ copyToClipboard,
4
+ type ExtensionAPI,
5
+ type ExtensionCommandContext,
6
+ type Theme,
7
+ } from "@mariozechner/pi-coding-agent";
8
+ import type { Focusable, TUI } from "@mariozechner/pi-tui";
9
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
+ import type { RetitleCommandOutcome, RetitleMode, RetitleScope } from "./command.js";
11
+ import {
12
+ type AutoRetitleStatus,
13
+ type AutoTitleFailure,
14
+ formatAutoTitleFailureSummary,
15
+ type SessionAutoTitleController,
16
+ } from "./controller.js";
17
+ import {
18
+ buildBulkRetitleMessage,
19
+ buildRetitleScopeScan,
20
+ buildScopeScanMessage,
21
+ formatScopeLocation,
22
+ getEligibleSessions,
23
+ notifyBulkRetitleResult,
24
+ type RetitleScopeScan,
25
+ runBulkRetitle,
26
+ runRetitlePlan,
27
+ } from "./retitle.js";
28
+
29
+ interface RetitleWizardOptions {
30
+ initialInvocation?: {
31
+ scope: Exclude<RetitleScope, "this">;
32
+ mode: RetitleMode;
33
+ };
34
+ }
35
+
36
+ type RetitleWizardStep =
37
+ | { kind: "scope" }
38
+ | { kind: "scan"; scope: Exclude<RetitleScope, "this"> }
39
+ | {
40
+ kind: "empty";
41
+ scan: RetitleScopeScan;
42
+ mode?: RetitleMode;
43
+ }
44
+ | {
45
+ kind: "confirm-mode";
46
+ scan: RetitleScopeScan;
47
+ }
48
+ | {
49
+ kind: "warning";
50
+ scan: RetitleScopeScan;
51
+ }
52
+ | {
53
+ kind: "running";
54
+ message: string;
55
+ };
56
+
57
+ export async function showRetitleWizard(
58
+ pi: ExtensionAPI,
59
+ controller: SessionAutoTitleController,
60
+ ctx: ExtensionCommandContext,
61
+ model: Model<Api> | undefined,
62
+ getSessionEpoch: () => number,
63
+ options?: RetitleWizardOptions,
64
+ ): Promise<RetitleCommandOutcome> {
65
+ return ctx.ui.custom<RetitleCommandOutcome>(
66
+ (tui, theme, _keybindings, done) =>
67
+ new RetitleWizardPanel(
68
+ tui,
69
+ theme,
70
+ pi,
71
+ controller,
72
+ ctx,
73
+ model,
74
+ getSessionEpoch,
75
+ done,
76
+ options,
77
+ ),
78
+ {
79
+ overlay: true,
80
+ overlayOptions: {
81
+ anchor: "center",
82
+ width: 88,
83
+ margin: 1,
84
+ },
85
+ },
86
+ );
87
+ }
88
+
89
+ class RetitleWizardPanel implements Focusable {
90
+ focused = false;
91
+
92
+ private selectedIndex = 0;
93
+ private step: RetitleWizardStep;
94
+
95
+ constructor(
96
+ private readonly tui: TUI,
97
+ private readonly theme: Theme,
98
+ private readonly pi: ExtensionAPI,
99
+ private readonly controller: SessionAutoTitleController,
100
+ private readonly ctx: ExtensionCommandContext,
101
+ private readonly model: Model<Api> | undefined,
102
+ private readonly getSessionEpoch: () => number,
103
+ private readonly done: (result: RetitleCommandOutcome) => void,
104
+ options?: RetitleWizardOptions,
105
+ ) {
106
+ this.step = options?.initialInvocation
107
+ ? { kind: "scan", scope: options.initialInvocation.scope }
108
+ : { kind: "scope" };
109
+
110
+ if (options?.initialInvocation) {
111
+ void this.scanScope(options.initialInvocation.scope, options.initialInvocation.mode);
112
+ }
113
+ }
114
+
115
+ invalidate(): void {}
116
+
117
+ handleInput(data: string): void {
118
+ if (matchesKey(data, "escape")) {
119
+ this.done("cancelled");
120
+ return;
121
+ }
122
+
123
+ switch (this.step.kind) {
124
+ case "scope":
125
+ this.handleScopeInput(data);
126
+ return;
127
+ case "confirm-mode":
128
+ this.handleConfirmModeInput(data);
129
+ return;
130
+ case "warning":
131
+ if (matchesKey(data, "enter")) {
132
+ void this.runBulkRetitle(this.step.scan, "all");
133
+ }
134
+ return;
135
+ case "empty":
136
+ if (matchesKey(data, "enter")) {
137
+ this.done("success");
138
+ }
139
+ return;
140
+ case "scan":
141
+ case "running":
142
+ return;
143
+ }
144
+ }
145
+
146
+ render(width: number): string[] {
147
+ const innerWidth = Math.max(1, width - 2);
148
+ const lines: string[] = [this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)];
149
+
150
+ switch (this.step.kind) {
151
+ case "scope":
152
+ this.renderScopeStep(lines, innerWidth);
153
+ break;
154
+ case "scan":
155
+ this.renderScanStep(lines, innerWidth, this.step.scope);
156
+ break;
157
+ case "empty":
158
+ this.renderEmptyStep(lines, innerWidth, this.step.scan, this.step.mode);
159
+ break;
160
+ case "confirm-mode":
161
+ this.renderConfirmModeStep(lines, innerWidth, this.step.scan);
162
+ break;
163
+ case "warning":
164
+ this.renderWarningStep(lines, innerWidth, this.step.scan.totalCount);
165
+ break;
166
+ case "running":
167
+ this.renderRunningStep(lines, innerWidth, this.step.message);
168
+ break;
169
+ }
170
+
171
+ lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
172
+ return lines;
173
+ }
174
+
175
+ private handleScopeInput(data: string): void {
176
+ const options: RetitleScope[] = ["this", "folder", "global"];
177
+ if (matchesKey(data, "up") || data === "k") {
178
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
179
+ this.requestRender();
180
+ return;
181
+ }
182
+
183
+ if (matchesKey(data, "down") || data === "j") {
184
+ this.selectedIndex = Math.min(options.length - 1, this.selectedIndex + 1);
185
+ this.requestRender();
186
+ return;
187
+ }
188
+
189
+ if ((data === "y" || data === "Y") && this.controller.getLastFailure(this.ctx)) {
190
+ void this.copyLastFailure();
191
+ return;
192
+ }
193
+
194
+ if (data === "t" || data === "T") {
195
+ void this.runCurrentSessionRetitle();
196
+ return;
197
+ }
198
+
199
+ if (data === "f" || data === "F") {
200
+ void this.scanScope("folder");
201
+ return;
202
+ }
203
+
204
+ if (data === "p" || data === "P") {
205
+ void this.scanScope("global");
206
+ return;
207
+ }
208
+
209
+ if (matchesKey(data, "enter")) {
210
+ const selected = options[this.selectedIndex];
211
+ if (selected === "this") {
212
+ void this.runCurrentSessionRetitle();
213
+ return;
214
+ }
215
+
216
+ if (selected) {
217
+ void this.scanScope(selected);
218
+ }
219
+ }
220
+ }
221
+
222
+ private handleConfirmModeInput(data: string): void {
223
+ // RetitleWizardStep uses anonymous inline members; Extract narrows to the correct variant
224
+ const { scan } = this.step as Extract<RetitleWizardStep, { kind: "confirm-mode" }>;
225
+
226
+ if (matchesKey(data, "enter")) {
227
+ void this.runBulkRetitle(scan, "backfill");
228
+ return;
229
+ }
230
+
231
+ if (data === "a" || data === "A") {
232
+ if (scan.scope === "global") {
233
+ this.step = { kind: "warning", scan };
234
+ this.requestRender();
235
+ return;
236
+ }
237
+
238
+ void this.runBulkRetitle(scan, "all");
239
+ }
240
+ }
241
+
242
+ private async runCurrentSessionRetitle(): Promise<void> {
243
+ this.step = { kind: "running", message: "Generating title for this session..." };
244
+ this.requestRender();
245
+ await this.ctx.waitForIdle();
246
+
247
+ const result = await runRetitlePlan({
248
+ pi: this.pi,
249
+ controller: this.controller,
250
+ ctx: this.ctx,
251
+ model: this.model,
252
+ isManual: true,
253
+ getSessionEpoch: this.getSessionEpoch,
254
+ });
255
+ if (result.ok) {
256
+ this.done("success");
257
+ return;
258
+ }
259
+
260
+ this.controller.handleTitleFailed(this.ctx, result.failure);
261
+ this.step = { kind: "scope" };
262
+ this.requestRender();
263
+ }
264
+
265
+ private async scanScope(
266
+ scope: Exclude<RetitleScope, "this">,
267
+ fixedMode?: RetitleMode,
268
+ ): Promise<void> {
269
+ this.step = { kind: "scan", scope };
270
+ this.requestRender();
271
+
272
+ try {
273
+ const scan = await buildRetitleScopeScan(this.ctx, scope);
274
+ if (scan.totalCount === 0) {
275
+ this.step = { kind: "empty", scan };
276
+ this.requestRender();
277
+ return;
278
+ }
279
+
280
+ if (fixedMode && getEligibleSessions(scan.sessions, fixedMode).length === 0) {
281
+ this.step = { kind: "empty", scan, mode: fixedMode };
282
+ this.requestRender();
283
+ return;
284
+ }
285
+
286
+ this.step = { kind: "confirm-mode", scan };
287
+ this.requestRender();
288
+ } catch {
289
+ this.done("failed");
290
+ }
291
+ }
292
+
293
+ private async runBulkRetitle(scan: RetitleScopeScan, mode: RetitleMode): Promise<void> {
294
+ if (mode === "backfill" && getEligibleSessions(scan.sessions, "backfill").length === 0) {
295
+ this.step = { kind: "empty", scan, mode: "backfill" };
296
+ this.requestRender();
297
+ return;
298
+ }
299
+
300
+ this.step = { kind: "running", message: buildBulkRetitleMessage(scan.scope, mode) };
301
+ this.requestRender();
302
+ await this.ctx.waitForIdle();
303
+
304
+ const result = await runBulkRetitle(
305
+ this.pi,
306
+ this.controller,
307
+ this.ctx,
308
+ this.model,
309
+ scan,
310
+ mode,
311
+ this.getSessionEpoch,
312
+ );
313
+ notifyBulkRetitleResult(this.ctx, scan, mode, result);
314
+ this.done(
315
+ result.failed > 0 && result.retitled === 0 && result.unchanged === 0 ? "failed" : "success",
316
+ );
317
+ }
318
+
319
+ private renderScopeStep(lines: string[], innerWidth: number): void {
320
+ const autoRetitleStatus = this.controller.getAutoRetitleStatus(this.ctx);
321
+ const failure = this.controller.getLastFailure(this.ctx);
322
+ const options: Array<{ key: string; suffix: string; selected: boolean }> = [
323
+ {
324
+ key: "t",
325
+ suffix:
326
+ autoRetitleStatus.mode === "paused_manual"
327
+ ? " Regenerate title for this session (restarts auto-updates)"
328
+ : " Regenerate title for this session",
329
+ selected: this.selectedIndex === 0,
330
+ },
331
+ {
332
+ key: "f",
333
+ suffix: " Generate titles for all sessions in this folder",
334
+ selected: this.selectedIndex === 1,
335
+ },
336
+ {
337
+ key: "p",
338
+ suffix: " Generate titles for all sessions of Pi",
339
+ selected: this.selectedIndex === 2,
340
+ },
341
+ ];
342
+
343
+ lines.push(this.renderHeaderRow(innerWidth, "Session Titles"));
344
+ lines.push(this.renderRow(innerWidth, ""));
345
+ for (const titleLine of formatCurrentSessionTitleLines(
346
+ this.theme,
347
+ this.ctx.sessionManager.getSessionName(),
348
+ innerWidth,
349
+ )) {
350
+ lines.push(this.renderRow(innerWidth, titleLine));
351
+ }
352
+ lines.push(
353
+ this.renderRow(innerWidth, formatAutoRetitleStatusLine(this.theme, autoRetitleStatus)),
354
+ );
355
+ lines.push(this.renderRow(innerWidth, ""));
356
+
357
+ for (const option of options) {
358
+ const prefix = option.selected ? this.theme.fg("accent", "›") : " ";
359
+ const text = `${this.theme.bold(option.key)}${option.suffix}`;
360
+ const label = option.selected ? this.theme.fg("accent", text) : text;
361
+ lines.push(this.renderRow(innerWidth, ` ${prefix} ${label}`));
362
+ }
363
+
364
+ if (failure) {
365
+ lines.push(this.renderRow(innerWidth, ""));
366
+ lines.push(
367
+ this.renderRow(
368
+ innerWidth,
369
+ ` ${this.theme.fg("dim", this.theme.bold("y"))}${this.theme.fg("dim", " Copy last auto-title error")}`,
370
+ ),
371
+ );
372
+ }
373
+ }
374
+
375
+ private renderScanStep(
376
+ lines: string[],
377
+ innerWidth: number,
378
+ scope: Exclude<RetitleScope, "this">,
379
+ ): void {
380
+ lines.push(this.renderHeaderRow(innerWidth, "Session Titles"));
381
+ lines.push(this.renderRow(innerWidth, ""));
382
+ lines.push(this.renderRow(innerWidth, ` ${buildScopeScanMessage(scope)}`));
383
+ }
384
+
385
+ private renderEmptyStep(
386
+ lines: string[],
387
+ innerWidth: number,
388
+ scan: RetitleScopeScan,
389
+ mode?: RetitleMode,
390
+ ): void {
391
+ const location = formatScopeLocation(scan.scope);
392
+ let message: string;
393
+ if (scan.totalCount === 0) {
394
+ message = `No sessions found ${location}.`;
395
+ } else if (mode === "backfill") {
396
+ message = `No untitled sessions found ${location}.`;
397
+ } else {
398
+ message = `No sessions matched ${location}.`;
399
+ }
400
+
401
+ lines.push(this.renderHeaderRow(innerWidth, "Session Titles"));
402
+ lines.push(this.renderRow(innerWidth, ""));
403
+ lines.push(this.renderRow(innerWidth, ` ${message}`));
404
+ lines.push(this.renderRow(innerWidth, ""));
405
+ lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Enter")} close`));
406
+ lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`));
407
+ }
408
+
409
+ private renderConfirmModeStep(lines: string[], innerWidth: number, scan: RetitleScopeScan): void {
410
+ const title = scan.scope === "folder" ? "In This Folder" : "All of Pi";
411
+ lines.push(this.renderRow(innerWidth, ` ${this.theme.bold(this.theme.fg("accent", title))}`));
412
+ lines.push(this.renderRow(innerWidth, ""));
413
+ lines.push(
414
+ this.renderRow(
415
+ innerWidth,
416
+ ` ${this.theme.bold("Enter")} Generate missing titles (${scan.untitledCount})`,
417
+ ),
418
+ );
419
+ lines.push(
420
+ this.renderRow(
421
+ innerWidth,
422
+ ` ${this.theme.bold("a")} Regenerate all sessions, even ones that already have titles (${scan.totalCount})`,
423
+ ),
424
+ );
425
+ lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`));
426
+ }
427
+
428
+ private renderWarningStep(lines: string[], innerWidth: number, sessionCount: number): void {
429
+ lines.push(
430
+ this.renderRow(innerWidth, ` ${this.theme.bold(this.theme.fg("error", "WARNING"))}`),
431
+ );
432
+ lines.push(this.renderRow(innerWidth, ""));
433
+ lines.push(
434
+ this.renderRow(innerWidth, ` This will retitle ${sessionCount} sessions across all of Pi.`),
435
+ );
436
+ lines.push(this.renderRow(innerWidth, " This may take some time and may be expensive."));
437
+ lines.push(this.renderRow(innerWidth, ""));
438
+ lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("accent", "Enter")} continue`));
439
+ lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`));
440
+ }
441
+
442
+ private renderRunningStep(lines: string[], innerWidth: number, message: string): void {
443
+ lines.push(this.renderHeaderRow(innerWidth, "Session Titles"));
444
+ lines.push(this.renderRow(innerWidth, ""));
445
+ lines.push(this.renderRow(innerWidth, ` ${message}`));
446
+ }
447
+
448
+ private async copyLastFailure(): Promise<void> {
449
+ const failure = this.controller.getLastFailure(this.ctx);
450
+ if (!failure) {
451
+ return;
452
+ }
453
+
454
+ await copyToClipboard(formatFailureClipboardText(failure));
455
+ this.ctx.ui.notify("Copied auto-title error to clipboard.", "info");
456
+ this.done("success");
457
+ }
458
+
459
+ private renderHeaderRow(innerWidth: number, title: string): string {
460
+ const left = ` ${this.theme.bold(this.theme.fg("accent", title))}`;
461
+ const failure = this.controller.getLastFailure(this.ctx);
462
+ if (!failure) {
463
+ return this.renderRow(innerWidth, left);
464
+ }
465
+
466
+ const maxSummaryWidth = Math.max(0, innerWidth - visibleWidth(left) - 1);
467
+ if (maxSummaryWidth < 8) {
468
+ return this.renderRow(innerWidth, left);
469
+ }
470
+
471
+ const summaryText = truncateToWidth(
472
+ formatAutoTitleFailureSummary(failure),
473
+ maxSummaryWidth,
474
+ "…",
475
+ );
476
+ const right = ` ${this.theme.fg("dim", summaryText)}`;
477
+ const gap = Math.max(1, innerWidth - visibleWidth(left) - visibleWidth(right));
478
+ return this.renderRow(innerWidth, `${left}${" ".repeat(gap)}${right}`);
479
+ }
480
+
481
+ private renderRow(innerWidth: number, content: string): string {
482
+ const pad = Math.max(0, innerWidth - visibleWidth(content));
483
+ return `${this.theme.fg("border", "│")}${content}${" ".repeat(pad)}${this.theme.fg("border", "│")}`;
484
+ }
485
+
486
+ private requestRender(): void {
487
+ this.tui.requestRender();
488
+ }
489
+ }
490
+
491
+ function formatCurrentSessionTitleLines(
492
+ theme: Theme,
493
+ title: string | undefined,
494
+ innerWidth: number,
495
+ ): string[] {
496
+ const prefix = "This session: ";
497
+ const value = title?.trim() || "(untitled)";
498
+ const availableTitleWidth = Math.max(1, innerWidth - visibleWidth(` ${prefix}`));
499
+ const titleLines = wrapText(value, availableTitleWidth);
500
+
501
+ return titleLines.map((line, index) => {
502
+ if (index === 0) {
503
+ return ` ${theme.fg("dim", prefix)}${line}`;
504
+ }
505
+
506
+ return ` ${" ".repeat(visibleWidth(prefix))}${line}`;
507
+ });
508
+ }
509
+
510
+ function formatAutoRetitleStatusLine(theme: Theme, status: AutoRetitleStatus): string {
511
+ if (status.mode === "paused_manual") {
512
+ return ` ${theme.fg("dim", "(Auto-update disabled)")}`;
513
+ }
514
+
515
+ if (status.turnsUntilAutoRetitle === 0) {
516
+ return ` ${theme.fg("dim", "(Auto-update due now)")}`;
517
+ }
518
+
519
+ const turnLabel = status.turnsUntilAutoRetitle === 1 ? "turn" : "turns";
520
+ return ` ${theme.fg("dim", `(Auto-update in ${status.turnsUntilAutoRetitle} ${turnLabel})`)}`;
521
+ }
522
+
523
+ function formatFailureClipboardText(failure: AutoTitleFailure): string {
524
+ const lines = [
525
+ `model: ${failure.model}`,
526
+ ...(failure.status !== undefined ? [`status: ${failure.status}`] : []),
527
+ `trigger: ${failure.trigger}`,
528
+ `time: ${failure.at}`,
529
+ `error: ${failure.message}`,
530
+ ];
531
+ return lines.join("\n");
532
+ }
533
+
534
+ function wrapText(text: string, width: number): string[] {
535
+ const words = text.split(/\s+/).filter(Boolean);
536
+ if (words.length === 0) {
537
+ return [""];
538
+ }
539
+
540
+ const lines: string[] = [];
541
+ let currentLine = "";
542
+
543
+ for (const word of words) {
544
+ if (!currentLine) {
545
+ currentLine = word;
546
+ continue;
547
+ }
548
+
549
+ const nextLine = `${currentLine} ${word}`;
550
+ if (visibleWidth(nextLine) <= width) {
551
+ currentLine = nextLine;
552
+ continue;
553
+ }
554
+
555
+ lines.push(...splitLongWord(currentLine, width));
556
+ currentLine = word;
557
+ }
558
+
559
+ if (currentLine) {
560
+ lines.push(...splitLongWord(currentLine, width));
561
+ }
562
+
563
+ return lines;
564
+ }
565
+
566
+ function splitLongWord(word: string, width: number): string[] {
567
+ if (visibleWidth(word) <= width) {
568
+ return [word];
569
+ }
570
+
571
+ const parts: string[] = [];
572
+ let remaining = word;
573
+ while (visibleWidth(remaining) > width) {
574
+ parts.push(remaining.slice(0, width));
575
+ remaining = remaining.slice(width);
576
+ }
577
+
578
+ if (remaining) {
579
+ parts.push(remaining);
580
+ }
581
+
582
+ return parts;
583
+ }