pi-sessions 0.12.1 → 0.13.1

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 (35) hide show
  1. package/README.md +6 -3
  2. package/extensions/pi-sessions.ts +20 -6
  3. package/extensions/session-ask/install.ts +24 -10
  4. package/extensions/session-auto-title/generate.ts +17 -14
  5. package/extensions/session-auto-title/install.ts +6 -29
  6. package/extensions/session-auto-title/retitle.ts +6 -19
  7. package/extensions/session-auto-title/wizard.ts +97 -31
  8. package/extensions/session-handoff/board-loading.ts +93 -0
  9. package/extensions/session-handoff/board.ts +239 -127
  10. package/extensions/session-handoff/install.ts +5 -5
  11. package/extensions/session-handoff/picker.ts +167 -19
  12. package/extensions/session-handoff/review.ts +56 -7
  13. package/extensions/session-handoff/ui.ts +32 -7
  14. package/extensions/session-hooks/install.ts +7 -20
  15. package/extensions/session-index/install.ts +8 -86
  16. package/extensions/session-index/loader.ts +80 -0
  17. package/extensions/session-index/panel.ts +103 -0
  18. package/extensions/session-messaging/broker/spawn.ts +35 -12
  19. package/extensions/session-messaging/pi/reachable-tool.ts +6 -1
  20. package/extensions/session-messaging/pi/tools.ts +79 -12
  21. package/extensions/session-search/extract.ts +120 -91
  22. package/extensions/session-search/hooks.ts +25 -203
  23. package/extensions/session-search/reindex.ts +13 -8
  24. package/extensions/shared/legend.ts +116 -0
  25. package/extensions/shared/session-index/scoring.ts +0 -1
  26. package/extensions/shared/session-index/search.ts +76 -49
  27. package/extensions/shared/settings.ts +22 -0
  28. package/extensions/subagents/context-limit.ts +58 -0
  29. package/extensions/subagents/install.ts +110 -30
  30. package/extensions/subagents/reconcile.ts +58 -19
  31. package/extensions/subagents/report.ts +0 -2
  32. package/extensions/subagents/settle.ts +31 -0
  33. package/extensions/subagents/should-message.ts +59 -0
  34. package/package.json +13 -13
  35. package/extensions/session-handoff/refs.ts +0 -145
@@ -5,9 +5,12 @@ import {
5
5
  type KeyId,
6
6
  matchesKey,
7
7
  type TUI,
8
+ type TuiMouseEvent,
9
+ type TuiMouseEventResult,
8
10
  truncateToWidth,
9
11
  visibleWidth,
10
12
  } from "@earendil-works/pi-tui";
13
+ import { type LegendHit, LegendPointer, layoutLegend, legendHitAt } from "../shared/legend.ts";
11
14
  import {
12
15
  stripSearchSnippetMarkers,
13
16
  transformSearchSnippetMatches,
@@ -24,6 +27,12 @@ interface PickerRightColumnWidths {
24
27
  modifiedAt: number;
25
28
  }
26
29
 
30
+ interface PickerSessionHit {
31
+ row: number;
32
+ end: number;
33
+ sessionId: string;
34
+ }
35
+
27
36
  export type SessionPickerResult =
28
37
  | { kind: "cancel" }
29
38
  | { kind: "insert-session-token"; sessionId: string };
@@ -66,6 +75,11 @@ export class SessionReferencePickerComponent implements Focusable {
66
75
  private includeAll = false;
67
76
  private items: SessionPickerItem[] = [];
68
77
  private selectedIndex = 0;
78
+ private sessionHits: PickerSessionHit[] = [];
79
+ private actionRows = new Map<number, LegendHit[]>();
80
+ private inputBounds: { row: number; width: number } | undefined;
81
+ private pressedSessionId: string | undefined;
82
+ private readonly legendPointer = new LegendPointer(() => this.tui.requestRender());
69
83
  private searchReloadTimer: ReturnType<typeof setTimeout> | undefined;
70
84
 
71
85
  get focused(): boolean {
@@ -101,10 +115,7 @@ export class SessionReferencePickerComponent implements Focusable {
101
115
  }
102
116
 
103
117
  if (this.keybindings.matches(data, "tui.input.tab")) {
104
- this.includeAll = !this.includeAll;
105
- this.cancelSearchReload();
106
- this.reload();
107
- this.tui.requestRender();
118
+ this.setScope(!this.includeAll);
108
119
  return;
109
120
  }
110
121
 
@@ -133,11 +144,7 @@ export class SessionReferencePickerComponent implements Focusable {
133
144
  }
134
145
 
135
146
  if (this.keybindings.matches(data, "tui.select.confirm")) {
136
- this.flushSearchReload();
137
- const selected = this.items[this.selectedIndex];
138
- if (selected?.kind === "session") {
139
- this.finish({ kind: "insert-session-token", sessionId: selected.sessionId });
140
- }
147
+ this.confirmSelection();
141
148
  return;
142
149
  }
143
150
 
@@ -149,12 +156,88 @@ export class SessionReferencePickerComponent implements Focusable {
149
156
  }
150
157
  }
151
158
 
159
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
160
+ if (event.type === "press") this.pressedSessionId = undefined;
161
+ const action =
162
+ event.type === "press"
163
+ ? legendHitAt(this.actionRows.get(event.y) ?? [], event.x - 1)
164
+ : undefined;
165
+ const legend = this.legendPointer.handleMouse(event, action);
166
+ if (legend) return event.type === "press" ? { ...legend, focus: true } : legend;
167
+ if (event.type === "press") {
168
+ if (event.button !== "left") return undefined;
169
+ const session = this.sessionAt(event);
170
+ if (session) {
171
+ this.pressedSessionId = session.sessionId;
172
+ const index = this.items.findIndex(
173
+ (item) => item.kind === "session" && item.sessionId === session.sessionId,
174
+ );
175
+ if (index >= 0) this.selectedIndex = index;
176
+ return { handled: true, focus: true };
177
+ }
178
+ return undefined;
179
+ }
180
+ if (event.type === "wheel" && event.wheelDelta && this.sessionAt(event)) {
181
+ this.moveSelection(event.wheelDelta < 0 ? -1 : 1);
182
+ return { handled: true };
183
+ }
184
+ if (event.type !== "click" || event.button !== "left") return undefined;
185
+ const sessionId = this.pressedSessionId;
186
+ this.pressedSessionId = undefined;
187
+ if (sessionId) {
188
+ this.finish({ kind: "insert-session-token", sessionId });
189
+ return { handled: true };
190
+ }
191
+ const input = this.inputBounds;
192
+ if (input && event.y === input.row && event.x >= 1 && event.x < input.width + 1) {
193
+ // Input places its cursor on press; defer that call until click so text drags stay unhandled.
194
+ return this.input.handleMouse({
195
+ ...event,
196
+ type: "press",
197
+ x: event.x - 1,
198
+ y: 0,
199
+ width: input.width,
200
+ height: 1,
201
+ });
202
+ }
203
+ return undefined;
204
+ }
205
+
206
+ private sessionAt(event: TuiMouseEvent): PickerSessionHit | undefined {
207
+ return this.sessionHits.find((hit) => hit.row === event.y && event.x >= 1 && event.x < hit.end);
208
+ }
209
+
210
+ private setScope(includeAll: boolean): void {
211
+ this.includeAll = includeAll;
212
+ this.cancelSearchReload();
213
+ this.reload();
214
+ this.tui.requestRender();
215
+ }
216
+
217
+ private confirmSelection(): void {
218
+ this.flushSearchReload();
219
+ const selected = this.items[this.selectedIndex];
220
+ if (selected?.kind === "session") {
221
+ this.finish({ kind: "insert-session-token", sessionId: selected.sessionId });
222
+ }
223
+ }
224
+
152
225
  render(width: number): string[] {
226
+ this.sessionHits = [];
227
+ this.actionRows.clear();
228
+ this.inputBounds = undefined;
153
229
  const panelWidth = Math.max(0, width);
154
230
  const innerWidth = Math.max(0, panelWidth - 2);
155
- const scopeText = this.includeAll
156
- ? `${this.theme.fg("muted", "○ Current Folder | ")}${this.theme.fg("accent", "◉ All")}`
157
- : `${this.theme.fg("accent", "◉ Current Folder")}${this.theme.fg("muted", " | ○ All")}`;
231
+ const folder = this.theme.fg(
232
+ this.includeAll ? "muted" : "accent",
233
+ `${this.includeAll ? "○" : "◉"} Current Folder`,
234
+ );
235
+ const all = this.theme.fg(
236
+ this.includeAll ? "accent" : "muted",
237
+ `${this.includeAll ? "◉" : "○"} All`,
238
+ );
239
+ const separator = this.theme.fg("muted", " | ");
240
+ const scopeText = `${folder}${separator}${all}`;
158
241
  const title = this.theme.bold("Add Session Reference to Prompt");
159
242
  const titleWidth = Math.max(0, innerWidth - visibleWidth(scopeText) - 1);
160
243
  const titleText = truncateToWidth(title, titleWidth, "…", true);
@@ -164,15 +247,57 @@ export class SessionReferencePickerComponent implements Focusable {
164
247
  );
165
248
 
166
249
  const lines = [this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)];
167
- lines.push(this.renderRow(`${titleText}${" ".repeat(headerSpacing)}${scopeText}`, innerWidth));
250
+ const scopeStart = visibleWidth(titleText) + headerSpacing;
251
+ if (scopeStart + visibleWidth(scopeText) <= innerWidth) {
252
+ const folderEnd = scopeStart + visibleWidth(folder);
253
+ const allStart = folderEnd + visibleWidth(separator);
254
+ this.actionRows.set(1, [
255
+ { id: "scope:folder", start: scopeStart, end: folderEnd, run: () => this.setScope(false) },
256
+ {
257
+ id: "scope:all",
258
+ start: allStart,
259
+ end: allStart + visibleWidth(all),
260
+ run: () => this.setScope(true),
261
+ },
262
+ ]);
263
+ }
264
+ const shownFolder =
265
+ this.actionRows.has(1) && this.legendPointer.pressedId === "scope:folder"
266
+ ? this.theme.bg("selectedBg", folder)
267
+ : folder;
268
+ const shownAll =
269
+ this.actionRows.has(1) && this.legendPointer.pressedId === "scope:all"
270
+ ? this.theme.bg("selectedBg", all)
271
+ : all;
168
272
  lines.push(
169
273
  this.renderRow(
170
- this.theme.fg("muted", "enter add to prompt · esc cancel · tab scope"),
274
+ `${titleText}${" ".repeat(headerSpacing)}${shownFolder}${separator}${shownAll}`,
171
275
  innerWidth,
172
276
  ),
173
277
  );
278
+ const selected = this.items[this.selectedIndex];
279
+ const legend = layoutLegend(
280
+ this.theme,
281
+ [
282
+ {
283
+ key: "enter",
284
+ description: "add to prompt",
285
+ run: () => {
286
+ if (selected?.kind === "session") {
287
+ this.finish({ kind: "insert-session-token", sessionId: selected.sessionId });
288
+ }
289
+ },
290
+ },
291
+ { key: "esc", description: "cancel", run: () => this.finish({ kind: "cancel" }) },
292
+ { key: "tab", description: "scope", run: () => this.setScope(!this.includeAll) },
293
+ ],
294
+ { width: innerWidth, separator: " · ", pressedId: this.legendPointer.pressedId },
295
+ );
296
+ this.actionRows.set(lines.length, legend.hits);
297
+ lines.push(this.renderRow(legend.text, innerWidth));
174
298
  lines.push(this.renderRow("", innerWidth));
175
299
 
300
+ this.inputBounds = { row: lines.length, width: innerWidth };
176
301
  for (const inputLine of this.input.render(innerWidth)) {
177
302
  lines.push(this.renderRow(inputLine, innerWidth));
178
303
  }
@@ -183,7 +308,13 @@ export class SessionReferencePickerComponent implements Focusable {
183
308
  const rightWidths = this.getRightColumnWidths();
184
309
  for (const { item, index } of visibleItems) {
185
310
  lines.push(
186
- ...this.renderPickerItem(item, index === this.selectedIndex, innerWidth, rightWidths),
311
+ ...this.renderPickerItem(
312
+ item,
313
+ index === this.selectedIndex,
314
+ innerWidth,
315
+ rightWidths,
316
+ lines.length,
317
+ ),
187
318
  );
188
319
  }
189
320
 
@@ -210,6 +341,7 @@ export class SessionReferencePickerComponent implements Focusable {
210
341
  selected: boolean,
211
342
  innerWidth: number,
212
343
  rightWidths: PickerRightColumnWidths,
344
+ firstRow: number,
213
345
  ): string[] {
214
346
  if (item.kind !== "session") {
215
347
  const message = [item.title, item.description].filter(Boolean).join(" — ");
@@ -232,7 +364,9 @@ export class SessionReferencePickerComponent implements Focusable {
232
364
  innerWidth - 2 - visibleWidth(cursor) - visibleWidth(left) - visibleWidth(right),
233
365
  );
234
366
  const titleLine = `${cursor}${left}${" ".repeat(spacing)}${right}`;
235
- const lines = [this.renderSelectableRow(titleLine, innerWidth, selected)];
367
+ const lines = [
368
+ this.renderSelectableRow(titleLine, innerWidth, selected, firstRow, item.sessionId),
369
+ ];
236
370
 
237
371
  if (item.snippet && !highlightedTitle) {
238
372
  const snippet = this.formatSnippet(
@@ -241,7 +375,13 @@ export class SessionReferencePickerComponent implements Focusable {
241
375
  );
242
376
  if (snippet) {
243
377
  lines.push(
244
- this.renderSelectableRow(` ${this.theme.fg("dim", snippet)}`, innerWidth, selected),
378
+ this.renderSelectableRow(
379
+ ` ${this.theme.fg("dim", snippet)}`,
380
+ innerWidth,
381
+ selected,
382
+ firstRow + lines.length,
383
+ item.sessionId,
384
+ ),
245
385
  );
246
386
  }
247
387
  }
@@ -250,11 +390,19 @@ export class SessionReferencePickerComponent implements Focusable {
250
390
  }
251
391
 
252
392
  private renderRow(content: string, innerWidth: number): string {
393
+ content = truncateToWidth(content, innerWidth, "…");
253
394
  const pad = Math.max(0, innerWidth - visibleWidth(content));
254
395
  return `${this.theme.fg("border", "│")}${content}${" ".repeat(pad)}${this.theme.fg("border", "│")}`;
255
396
  }
256
397
 
257
- private renderSelectableRow(content: string, innerWidth: number, selected: boolean): string {
398
+ private renderSelectableRow(
399
+ content: string,
400
+ innerWidth: number,
401
+ selected: boolean,
402
+ row: number,
403
+ sessionId: string,
404
+ ): string {
405
+ this.sessionHits.push({ row, end: 1 + Math.min(innerWidth, visibleWidth(content)), sessionId });
258
406
  return this.renderRow(selected ? this.theme.bg("selectedBg", content) : content, innerWidth);
259
407
  }
260
408
 
@@ -277,7 +425,7 @@ export class SessionReferencePickerComponent implements Focusable {
277
425
  return undefined;
278
426
  }
279
427
 
280
- return truncateToWidth(rendered, maxWidth, "…", true);
428
+ return truncateToWidth(rendered, maxWidth, "…");
281
429
  }
282
430
 
283
431
  private highlightSnippetMatches(snippet: string): string | undefined {
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import type { Component } from "@earendil-works/pi-tui";
2
+ import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
3
3
  import {
4
4
  Key,
5
5
  matchesKey,
@@ -7,6 +7,13 @@ import {
7
7
  visibleWidth,
8
8
  wrapTextWithAnsi,
9
9
  } from "@earendil-works/pi-tui";
10
+ import {
11
+ type LegendHit,
12
+ type LegendItem,
13
+ LegendPointer,
14
+ layoutLegend,
15
+ legendHitAt,
16
+ } from "../shared/legend.ts";
10
17
  import { renderStrongModal } from "./strong-modal.ts";
11
18
 
12
19
  const PREVIEW_TIMEOUT_MS = 8_000;
@@ -59,6 +66,8 @@ export class HandoffPreviewComponent implements Component {
59
66
  private isTimerActive = true;
60
67
  private scrollOffset = 0;
61
68
  private previewWidth = 80;
69
+ private legendRows = new Map<number, LegendHit[]>();
70
+ private readonly legendPointer = new LegendPointer(() => this.requestRender());
62
71
 
63
72
  public constructor(
64
73
  draft: string,
@@ -119,7 +128,26 @@ export class HandoffPreviewComponent implements Component {
119
128
  }
120
129
  }
121
130
 
131
+ public handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
132
+ const hit =
133
+ event.type === "press" && !this.isDone
134
+ ? legendHitAt(this.legendRows.get(event.y) ?? [], event.x - 2)
135
+ : undefined;
136
+ const legend = this.legendPointer.handleMouse(event, hit);
137
+ if (legend) {
138
+ if (event.type === "press") this.disableTimer();
139
+ return legend;
140
+ }
141
+ if (event.type === "wheel" && event.wheelDelta && !this.isDone) {
142
+ this.disableTimer();
143
+ this.scrollBy(event.wheelDelta);
144
+ return { handled: true };
145
+ }
146
+ return undefined;
147
+ }
148
+
122
149
  public render(width: number): string[] {
150
+ this.legendRows.clear();
123
151
  const modalWidth = Math.max(24, width - 4);
124
152
  const promptBoxWidth = Math.max(20, modalWidth - 4);
125
153
  this.previewWidth = Math.max(16, promptBoxWidth - 4);
@@ -133,8 +161,18 @@ export class HandoffPreviewComponent implements Component {
133
161
  const contentLines = [
134
162
  this.getTitleLine(),
135
163
  "",
136
- this.theme.fg("muted", formatKeymapLine("Enter: start session", "Esc: cancel")),
137
- this.theme.fg("muted", formatKeymapLine("e: edit prompt", "j/k: scroll")),
164
+ this.renderKeymapLine(
165
+ 3,
166
+ width,
167
+ { key: "Enter:", description: "start session", run: () => this.finish("accept") },
168
+ { key: "Esc:", description: "cancel", run: () => this.finish("cancel") },
169
+ ),
170
+ this.renderKeymapLine(
171
+ 4,
172
+ width,
173
+ { key: "e:", description: "edit prompt", run: () => this.finish("edit") },
174
+ { key: "j/k:", description: "scroll" },
175
+ ),
138
176
  "",
139
177
  ...promptLines,
140
178
  ];
@@ -142,6 +180,21 @@ export class HandoffPreviewComponent implements Component {
142
180
  return renderStrongModal(contentLines, width, this.theme);
143
181
  }
144
182
 
183
+ private renderKeymapLine(
184
+ row: number,
185
+ width: number,
186
+ left: LegendItem,
187
+ right: LegendItem,
188
+ ): string {
189
+ const legend = layoutLegend(this.theme, [left, right], {
190
+ width: Math.max(20, width - 4),
191
+ pressedId: this.legendPointer.pressedId,
192
+ separator: " ".repeat(Math.max(0, 27 - visibleWidth(`${left.key} ${left.description}`))),
193
+ });
194
+ this.legendRows.set(row, legend.hits);
195
+ return legend.text;
196
+ }
197
+
145
198
  public invalidate(): void {}
146
199
 
147
200
  public stop(): void {
@@ -202,10 +255,6 @@ export class HandoffPreviewComponent implements Component {
202
255
  }
203
256
  }
204
257
 
205
- function formatKeymapLine(left: string, right: string): string {
206
- return `${left.padEnd(27, " ")}${right}`;
207
- }
208
-
209
258
  function renderPromptSection(lines: string[], width: number, _theme: Theme): string[] {
210
259
  const innerWidth = Math.max(16, width - 4);
211
260
  const line = (left: string, text: string, right: string) => {
@@ -1,5 +1,11 @@
1
1
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { Key, matchesKey } from "@earendil-works/pi-tui";
2
+ import {
3
+ Key,
4
+ matchesKey,
5
+ type TuiMouseEvent,
6
+ type TuiMouseEventResult,
7
+ } from "@earendil-works/pi-tui";
8
+ import { type LegendHit, LegendPointer, layoutLegend, legendHitAt } from "../shared/legend.ts";
3
9
  import { renderStrongModal } from "./strong-modal.ts";
4
10
 
5
11
  export async function runHandoffTaskWithLoader<T>(
@@ -12,6 +18,13 @@ export async function runHandoffTaskWithLoader<T>(
12
18
  const result = await ctx.ui.custom<T | undefined>(
13
19
  (tui, theme, _keybindings, done) => {
14
20
  const abortController = new AbortController();
21
+ let cancelHits: LegendHit[] = [];
22
+ const pointer = new LegendPointer(() => tui.requestRender());
23
+ const cancel = () => {
24
+ abortController.abort();
25
+ done(undefined);
26
+ tui.requestRender();
27
+ };
15
28
 
16
29
  task(abortController.signal)
17
30
  .then(done)
@@ -24,19 +37,31 @@ export async function runHandoffTaskWithLoader<T>(
24
37
 
25
38
  return {
26
39
  render(width: number): string[] {
40
+ const legend = layoutLegend(
41
+ theme,
42
+ [{ key: "Esc", description: "to cancel.", run: cancel }],
43
+ { width: Math.max(20, width - 4) - 6, pressedId: pointer.pressedId },
44
+ );
45
+ cancelHits = legend.hits;
27
46
  return renderStrongModal(
28
- [theme.fg("accent", theme.bold(label)), "", theme.fg("muted", "Press Esc to cancel.")],
47
+ [
48
+ theme.fg("accent", theme.bold(label)),
49
+ "",
50
+ `${theme.fg("muted", "Press ")}${legend.text}`,
51
+ ],
29
52
  width,
30
53
  theme,
31
54
  );
32
55
  },
33
56
  invalidate(): void {},
57
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
58
+ return pointer.handleMouse(
59
+ event,
60
+ event.y === 3 ? legendHitAt(cancelHits, event.x - 8) : undefined,
61
+ );
62
+ },
34
63
  handleInput(data: string): void {
35
- if (matchesKey(data, Key.escape)) {
36
- abortController.abort();
37
- done(undefined);
38
- tui.requestRender();
39
- }
64
+ if (matchesKey(data, Key.escape)) cancel();
40
65
  },
41
66
  };
42
67
  },
@@ -5,32 +5,20 @@ import {
5
5
  } from "../session-handoff/metadata.ts";
6
6
  import { createSessionHookController } from "../session-search/hooks.ts";
7
7
  import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
8
- import type { SessionSettings } from "../shared/settings.ts";
9
8
 
10
- export function installHooks(
11
- pi: ExtensionAPI,
12
- deps: { settings: SessionSettings; index: IndexHandle },
13
- ): SessionLifecycle {
9
+ export function installHooks(pi: ExtensionAPI, deps: { index: IndexHandle }): SessionLifecycle {
14
10
  const controller = createSessionHookController({ indexPath: deps.index.path });
15
11
 
16
- pi.on("tool_call", async (event, ctx) => {
17
- controller.handleToolCall(event, ctx.sessionManager.getSessionFile(), ctx.cwd);
18
- });
19
-
20
- pi.on("tool_result", async (event) => {
21
- controller.handleToolResult(event);
22
- });
23
-
24
12
  pi.on("turn_end", async (_event, ctx) => {
25
- await controller.handleTurnEnd(ctx.sessionManager.getSessionFile(), ctx.cwd);
13
+ await controller.handleTurnEnd(ctx.sessionManager.getSessionFile());
26
14
  });
27
15
 
28
16
  pi.on("session_tree", async (_event, ctx) => {
29
- await controller.handleSessionTree(ctx.sessionManager.getSessionFile(), ctx.cwd);
17
+ await controller.handleSessionTree(ctx.sessionManager.getSessionFile());
30
18
  });
31
19
 
32
20
  pi.on("session_compact", async (_event, ctx) => {
33
- await controller.handleSessionCompact(ctx.sessionManager.getSessionFile(), ctx.cwd);
21
+ await controller.handleSessionCompact(ctx.sessionManager.getSessionFile());
34
22
  });
35
23
 
36
24
  return {
@@ -44,20 +32,19 @@ export function installHooks(
44
32
  await controller.handleSessionSwitch(
45
33
  previousSessionFile,
46
34
  sessionFile,
47
- ctx.cwd,
48
35
  getSessionStartOrigin(ctx),
49
36
  );
50
37
  break;
51
38
  case "fork":
52
- await controller.handleSessionFork(previousSessionFile, sessionFile, ctx.cwd);
39
+ await controller.handleSessionFork(previousSessionFile, sessionFile);
53
40
  break;
54
41
  default:
55
- await controller.handleSessionStart(sessionFile, ctx.cwd);
42
+ await controller.handleSessionStart(sessionFile);
56
43
  break;
57
44
  }
58
45
  },
59
46
  async onSessionShutdown(_event, ctx) {
60
- await controller.handleSessionShutdown(ctx.sessionManager.getSessionFile(), ctx.cwd);
47
+ await controller.handleSessionShutdown(ctx.sessionManager.getSessionFile());
61
48
  },
62
49
  };
63
50
  }
@@ -1,17 +1,12 @@
1
- import {
2
- BorderedLoader,
3
- type ExtensionAPI,
4
- type ExtensionCommandContext,
5
- type Theme,
6
- } from "@earendil-works/pi-coding-agent";
7
- import { type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
2
  import { type ReindexResult, rebuildSessionIndex } from "../session-search/reindex.ts";
9
3
  import type { IndexHandle } from "../shared/composition.ts";
10
4
  import { isTuiMode } from "../shared/pi-mode.ts";
11
- import { getIndexStatus, type SessionIndexStatus } from "../shared/session-index/index.ts";
5
+ import { getIndexStatus } from "../shared/session-index/index.ts";
12
6
  import type { SessionSettings } from "../shared/settings.ts";
13
7
 
14
- type SessionIndexAction = "reindex" | undefined;
8
+ import { ReindexLoader } from "./loader.ts";
9
+ import { type SessionIndexAction, SessionIndexPanel } from "./panel.ts";
15
10
 
16
11
  export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings }): IndexHandle {
17
12
  const indexPath = deps.settings.index.path;
@@ -26,7 +21,8 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
26
21
 
27
22
  const status = getIndexStatus(indexPath);
28
23
  const action = await ctx.ui.custom<SessionIndexAction>(
29
- (_tui, theme, _keybindings, done) => new SessionIndexPanel(theme, status, done),
24
+ (tui, theme, _keybindings, done) =>
25
+ new SessionIndexPanel(theme, status, done, () => tui.requestRender()),
30
26
  {
31
27
  overlay: true,
32
28
  overlayOptions: {
@@ -66,78 +62,12 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
66
62
  return { path: indexPath };
67
63
  }
68
64
 
69
- class SessionIndexPanel implements Focusable {
70
- readonly width = 72;
71
- focused = false;
72
-
73
- invalidate(): void {}
74
-
75
- constructor(
76
- private readonly theme: Theme,
77
- private readonly status: SessionIndexStatus,
78
- private readonly done: (result: SessionIndexAction) => void,
79
- ) {}
80
-
81
- handleInput(data: string): void {
82
- if (isCloseKey(data)) {
83
- this.done(undefined);
84
- return;
85
- }
86
-
87
- if (isReindexKey(data)) {
88
- this.done("reindex");
89
- }
90
- }
91
-
92
- render(_width: number): string[] {
93
- const innerWidth = this.width - 2;
94
- const lines: string[] = [this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)];
95
-
96
- lines.push(
97
- this.renderRow(innerWidth, ` ${this.theme.bold(this.theme.fg("accent", "Session Index"))}`),
98
- );
99
- lines.push(this.renderRow(innerWidth, ""));
100
- lines.push(
101
- this.renderRow(
102
- innerWidth,
103
- ` Path: ${this.status.exists ? this.status.dbPath : "<no index found>"}`,
104
- ),
105
- );
106
- lines.push(
107
- this.renderRow(
108
- innerWidth,
109
- ` Schema version: ${this.status.schemaVersion !== undefined ? String(this.status.schemaVersion) : "n/a"}`,
110
- ),
111
- );
112
- lines.push(
113
- this.renderRow(
114
- innerWidth,
115
- ` Session count: ${this.status.sessionCount !== undefined ? String(this.status.sessionCount) : "n/a"}`,
116
- ),
117
- );
118
- lines.push(
119
- this.renderRow(innerWidth, ` Last full reindex: ${this.status.lastFullReindexAt ?? "n/a"}`),
120
- );
121
- lines.push(this.renderRow(innerWidth, ""));
122
- lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("accent", "R")} rebuild from disk`));
123
- lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Enter / Esc")} close`));
124
- lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`));
125
- return lines;
126
- }
127
-
128
- private renderRow(innerWidth: number, content: string): string {
129
- const pad = Math.max(0, innerWidth - visibleWidth(content));
130
- return `${this.theme.fg("border", "│")}${content}${" ".repeat(pad)}${this.theme.fg("border", "│")}`;
131
- }
132
- }
133
-
134
65
  async function runReindexWithLoader(
135
66
  ctx: ExtensionCommandContext,
136
67
  indexPath: string,
137
68
  ): Promise<ReindexResult | null> {
138
- return ctx.ui.custom<ReindexResult | null>((tui, theme, _keybindings, done) => {
139
- const loader = new BorderedLoader(tui, theme, "Rebuilding session index...");
140
- loader.onAbort = () => done(null);
69
+ return ctx.ui.custom<ReindexResult | null>((tui, theme, keybindings, done) => {
70
+ const loader = new ReindexLoader(tui, theme, keybindings, () => done(null));
141
71
 
142
72
  void (async () => {
143
73
  try {
@@ -152,11 +82,3 @@ async function runReindexWithLoader(
152
82
  return loader;
153
83
  });
154
84
  }
155
-
156
- function isCloseKey(data: string): boolean {
157
- return matchesKey(data, "escape") || matchesKey(data, "enter");
158
- }
159
-
160
- function isReindexKey(data: string): boolean {
161
- return data === "r" || data === "R" || matchesKey(data, "r");
162
- }