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
@@ -0,0 +1,93 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { SubagentRoster } from "../subagents/roster.ts";
3
+ import type { HandoffBoardSnapshot, UserSessionEntry } from "./board-view-model.ts";
4
+ import { HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE } from "./metadata.ts";
5
+ import { parseHandoffLaunchReceiptEntry } from "./receipt.ts";
6
+
7
+ export interface HandoffBoardServices {
8
+ roster?: SubagentRoster | undefined;
9
+ cancelSubagent?: ((sessionId: string) => Promise<unknown>) | undefined;
10
+ listLiveSessions?: (() => Promise<string[]>) | undefined;
11
+ readSessionEntries?: ((sessionFile: string) => readonly SessionEntry[]) | undefined;
12
+ }
13
+
14
+ export async function loadHandoffBoardSnapshot(
15
+ entries: readonly SessionEntry[],
16
+ services: HandoffBoardServices,
17
+ ): Promise<HandoffBoardSnapshot> {
18
+ const userSessions = collectUserSessions(entries);
19
+ const [branchRoster, liveSessionIds, hydratedUserSessions] = await Promise.all([
20
+ services.roster?.resolve("branch") ?? Promise.resolve({ entries: [], total: 0 }),
21
+ services.listLiveSessions?.() ?? Promise.resolve([]),
22
+ Promise.all(
23
+ userSessions.map(
24
+ async (entry): Promise<UserSessionEntry> => ({
25
+ ...entry,
26
+ runEvidence: loadUserSessionRunEvidence(entry, services),
27
+ }),
28
+ ),
29
+ ),
30
+ ]);
31
+ return {
32
+ subagents: branchRoster.entries,
33
+ userSessions: hydratedUserSessions,
34
+ liveSessionIds: new Set(liveSessionIds),
35
+ hasLiveSessionEvidence: services.listLiveSessions !== undefined,
36
+ };
37
+ }
38
+
39
+ export function collectUserSessions(entries: readonly SessionEntry[]): UserSessionEntry[] {
40
+ const bySessionId = new Map<string, UserSessionEntry>();
41
+ for (const entry of entries) {
42
+ const receipt = parseHandoffLaunchReceiptEntry(entry);
43
+ if (!receipt || receipt.launch === "subagent") {
44
+ continue;
45
+ }
46
+ const candidate = {
47
+ sessionId: receipt.sessionId,
48
+ timestamp: entry.timestamp,
49
+ receipt,
50
+ };
51
+ const existing = bySessionId.get(candidate.sessionId);
52
+ if (!existing || candidate.timestamp > existing.timestamp) {
53
+ bySessionId.set(candidate.sessionId, candidate);
54
+ }
55
+ }
56
+ return [...bySessionId.values()].sort((left, right) =>
57
+ right.timestamp.localeCompare(left.timestamp),
58
+ );
59
+ }
60
+
61
+ function loadUserSessionRunEvidence(
62
+ entry: UserSessionEntry,
63
+ services: HandoffBoardServices,
64
+ ): NonNullable<UserSessionEntry["runEvidence"]> {
65
+ if (!services.readSessionEntries) {
66
+ return unavailableRunEvidence();
67
+ }
68
+
69
+ try {
70
+ return {
71
+ transcriptAvailable: true,
72
+ hasStarted: services
73
+ .readSessionEntries(entry.receipt.childSessionFile)
74
+ .some(isSessionStartupEvidence),
75
+ };
76
+ } catch {
77
+ return unavailableRunEvidence();
78
+ }
79
+ }
80
+
81
+ function unavailableRunEvidence(): NonNullable<UserSessionEntry["runEvidence"]> {
82
+ return { transcriptAvailable: false, hasStarted: false };
83
+ }
84
+
85
+ function isSessionStartupEvidence(entry: SessionEntry): boolean {
86
+ if (entry.type === "session_info") {
87
+ return false;
88
+ }
89
+ if (entry.type !== "custom") {
90
+ return true;
91
+ }
92
+ return entry.customType !== HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE;
93
+ }
@@ -1,26 +1,43 @@
1
1
  import {
2
2
  copyToClipboard,
3
3
  type ExtensionCommandContext,
4
- type SessionEntry,
5
4
  type Theme,
6
5
  type ThemeColor,
7
6
  } from "@earendil-works/pi-coding-agent";
8
- import { type Focusable, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import {
8
+ type Focusable,
9
+ matchesKey,
10
+ type TuiMouseEvent,
11
+ type TuiMouseEventResult,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ } from "@earendil-works/pi-tui";
15
+ import {
16
+ type LegendHit,
17
+ type LegendItem,
18
+ type LegendLine,
19
+ LegendPointer,
20
+ layoutLegend,
21
+ legendHitAt,
22
+ } from "../shared/legend.ts";
9
23
  import { formatCompactRelativeTime } from "../shared/time.ts";
10
24
  import type { SubagentState } from "../subagents/classify.ts";
11
- import type { SubagentRoster } from "../subagents/roster.ts";
25
+ import { type HandoffBoardServices, loadHandoffBoardSnapshot } from "./board-loading.ts";
12
26
  import {
13
27
  buildHandoffBoardView,
14
28
  type HandoffBoardSnapshot,
15
29
  type HandoffBoardTab,
16
30
  type HandoffBoardView,
17
31
  type HandoffSubagentsView,
18
- type UserSessionEntry,
19
32
  type UserSessionStatus,
20
33
  type UserSessionsView,
21
34
  } from "./board-view-model.ts";
22
- import { HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE } from "./metadata.ts";
23
- import { parseHandoffLaunchReceiptEntry } from "./receipt.ts";
35
+
36
+ export {
37
+ collectUserSessions,
38
+ type HandoffBoardServices,
39
+ loadHandoffBoardSnapshot,
40
+ } from "./board-loading.ts";
24
41
 
25
42
  const BOARD_WIDTH = 86;
26
43
  const ROW_VIEWPORT_SIZE = 8;
@@ -28,11 +45,15 @@ const STATUS_DURATION_MS = 3_000;
28
45
 
29
46
  export type { HandoffBoardSnapshot, UserSessionEntry } from "./board-view-model.ts";
30
47
 
31
- export interface HandoffBoardServices {
32
- roster?: SubagentRoster | undefined;
33
- cancelSubagent?: ((sessionId: string) => Promise<unknown>) | undefined;
34
- listLiveSessions?: (() => Promise<string[]>) | undefined;
35
- readSessionEntries?: ((sessionFile: string) => readonly SessionEntry[]) | undefined;
48
+ interface BoardMouseTarget {
49
+ run(): void;
50
+ press?: () => void;
51
+ }
52
+
53
+ interface BoardRowHit {
54
+ tab: HandoffBoardTab;
55
+ sessionId: string;
56
+ end: number;
36
57
  }
37
58
 
38
59
  interface HandoffBoardActions {
@@ -74,87 +95,6 @@ export async function openHandoffBoard(
74
95
  );
75
96
  }
76
97
 
77
- export async function loadHandoffBoardSnapshot(
78
- entries: readonly SessionEntry[],
79
- services: HandoffBoardServices,
80
- ): Promise<HandoffBoardSnapshot> {
81
- const userSessions = collectUserSessions(entries);
82
- const [branchRoster, liveSessionIds, hydratedUserSessions] = await Promise.all([
83
- services.roster?.resolve("branch") ?? Promise.resolve({ entries: [], total: 0 }),
84
- services.listLiveSessions?.() ?? Promise.resolve([]),
85
- Promise.all(
86
- userSessions.map(
87
- async (entry): Promise<UserSessionEntry> => ({
88
- ...entry,
89
- runEvidence: loadUserSessionRunEvidence(entry, services),
90
- }),
91
- ),
92
- ),
93
- ]);
94
- return {
95
- subagents: branchRoster.entries,
96
- userSessions: hydratedUserSessions,
97
- liveSessionIds: new Set(liveSessionIds),
98
- hasLiveSessionEvidence: services.listLiveSessions !== undefined,
99
- };
100
- }
101
-
102
- export function collectUserSessions(entries: readonly SessionEntry[]): UserSessionEntry[] {
103
- const bySessionId = new Map<string, UserSessionEntry>();
104
- for (const entry of entries) {
105
- const receipt = parseHandoffLaunchReceiptEntry(entry);
106
- if (!receipt || receipt.launch === "subagent") {
107
- continue;
108
- }
109
- const candidate = {
110
- sessionId: receipt.sessionId,
111
- timestamp: entry.timestamp,
112
- receipt,
113
- };
114
- const existing = bySessionId.get(candidate.sessionId);
115
- if (!existing || candidate.timestamp > existing.timestamp) {
116
- bySessionId.set(candidate.sessionId, candidate);
117
- }
118
- }
119
- return [...bySessionId.values()].sort((left, right) =>
120
- right.timestamp.localeCompare(left.timestamp),
121
- );
122
- }
123
-
124
- function loadUserSessionRunEvidence(
125
- entry: UserSessionEntry,
126
- services: HandoffBoardServices,
127
- ): NonNullable<UserSessionEntry["runEvidence"]> {
128
- if (!services.readSessionEntries) {
129
- return unavailableRunEvidence();
130
- }
131
-
132
- try {
133
- return {
134
- transcriptAvailable: true,
135
- hasStarted: services
136
- .readSessionEntries(entry.receipt.childSessionFile)
137
- .some(isSessionStartupEvidence),
138
- };
139
- } catch {
140
- return unavailableRunEvidence();
141
- }
142
- }
143
-
144
- function unavailableRunEvidence(): NonNullable<UserSessionEntry["runEvidence"]> {
145
- return { transcriptAvailable: false, hasStarted: false };
146
- }
147
-
148
- function isSessionStartupEvidence(entry: SessionEntry): boolean {
149
- if (entry.type === "session_info") {
150
- return false;
151
- }
152
- if (entry.type !== "custom") {
153
- return true;
154
- }
155
- return entry.customType !== HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE;
156
- }
157
-
158
98
  export class HandoffBoard implements Focusable {
159
99
  focused = false;
160
100
  private tab: HandoffBoardTab = "subagents";
@@ -163,6 +103,10 @@ export class HandoffBoard implements Focusable {
163
103
  private status: string | undefined;
164
104
  private statusGeneration = 0;
165
105
  private busy = false;
106
+ private rowHits = new Map<number, BoardRowHit>();
107
+ private controlHits = new Map<number, LegendHit[]>();
108
+ private pressedTarget: BoardMouseTarget | undefined;
109
+ private readonly legendPointer = new LegendPointer(() => this.requestRender());
166
110
 
167
111
  constructor(
168
112
  private readonly theme: Theme,
@@ -225,31 +169,100 @@ export class HandoffBoard implements Focusable {
225
169
  }
226
170
  }
227
171
 
172
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
173
+ if (event.type === "press") this.pressedTarget = undefined;
174
+ const hit =
175
+ event.type === "press" && !this.busy
176
+ ? legendHitAt(this.controlHits.get(event.y) ?? [], event.x - 2)
177
+ : undefined;
178
+ const legend = this.legendPointer.handleMouse(
179
+ event,
180
+ hit
181
+ ? {
182
+ ...hit,
183
+ run: () => {
184
+ if (!this.busy) hit.run();
185
+ },
186
+ }
187
+ : undefined,
188
+ );
189
+ if (legend) return event.type === "press" ? { ...legend, focus: true } : legend;
190
+ if (event.type === "press") {
191
+ this.pressedTarget =
192
+ event.button === "left" && !this.busy ? this.mouseTargetAt(event) : undefined;
193
+ if (!this.pressedTarget) return undefined;
194
+ this.pressedTarget.press?.();
195
+ return { handled: true, focus: true };
196
+ }
197
+ if (event.type === "click") {
198
+ // Pi keeps press-time coordinates even when selection reflows the board.
199
+ const target = this.pressedTarget;
200
+ this.pressedTarget = undefined;
201
+ if (event.button !== "left" || !target || this.busy) return undefined;
202
+ target.run();
203
+ return { handled: true };
204
+ }
205
+ if (event.type === "wheel" && event.wheelDelta && this.rowHitAt(event)) {
206
+ if (!this.busy) this.moveSelection(Math.sign(event.wheelDelta));
207
+ return { handled: true };
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ private rowHitAt(event: TuiMouseEvent): BoardRowHit | undefined {
213
+ const hit = this.rowHits.get(event.y);
214
+ return hit && event.x >= 2 && event.x < hit.end ? hit : undefined;
215
+ }
216
+
217
+ private mouseTargetAt(event: TuiMouseEvent): BoardMouseTarget | undefined {
218
+ const row = this.rowHitAt(event);
219
+ if (row) {
220
+ const select = () => this.selectSession(row.tab, row.sessionId);
221
+ return { press: select, run: select };
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ private sessionIdAt(tab: HandoffBoardTab, index: number): string | undefined {
227
+ return tab === "subagents"
228
+ ? this.snapshot.subagents[index]?.sessionId
229
+ : this.snapshot.userSessions[index]?.sessionId;
230
+ }
231
+
232
+ private selectSession(tab: HandoffBoardTab, sessionId: string): void {
233
+ if (this.tab !== tab) return;
234
+ const entries = tab === "subagents" ? this.snapshot.subagents : this.snapshot.userSessions;
235
+ const index = entries.findIndex((entry) => entry.sessionId === sessionId);
236
+ if (index < 0) return;
237
+ this.selectedByTab[tab] = index;
238
+ this.clearTransientState();
239
+ this.requestRender();
240
+ }
241
+
228
242
  render(width: number): string[] {
243
+ this.rowHits.clear();
244
+ this.controlHits.clear();
229
245
  const modalWidth = Math.max(20, Math.min(width, BOARD_WIDTH));
230
246
  const innerWidth = modalWidth - 2;
231
247
  const bodyWidth = innerWidth - 2;
232
248
  const border = (text: string) => this.theme.fg("border", text);
233
249
  const row = (content = "") => `${border("│")} ${fitCell(content, bodyWidth)} ${border("│")}`;
234
250
  const view = this.currentView();
235
- const rows =
236
- view.tab === "subagents"
237
- ? this.renderSubagentRows(view, bodyWidth)
238
- : this.renderUserSessionRows(view, bodyWidth);
239
-
240
- return [
251
+ const lines = [
241
252
  border(`╭${"─".repeat(innerWidth)}╮`),
242
253
  row(this.renderHeader(view, bodyWidth)),
243
254
  row(),
244
- row(this.renderTabs()),
245
- row(),
246
- ...rows.map(row),
247
- row(),
248
- ...this.renderDetails(view, bodyWidth).map(row),
249
- row(),
250
- row(this.renderFooter(view, bodyWidth)),
251
- border(`╰${"─".repeat(innerWidth)}╯`),
252
255
  ];
256
+ lines.push(row(this.renderTabs(lines.length, bodyWidth)), row());
257
+ const rows =
258
+ view.tab === "subagents"
259
+ ? this.renderSubagentRows(view, bodyWidth, lines.length + 1)
260
+ : this.renderUserSessionRows(view, bodyWidth, lines.length + 1);
261
+ lines.push(...rows.map(row), row(), ...this.renderDetails(view, bodyWidth).map(row), row());
262
+ const footer = this.renderFooter(view, bodyWidth);
263
+ this.controlHits.set(lines.length, footer.hits);
264
+ lines.push(row(footer.text), border(`╰${"─".repeat(innerWidth)}╯`));
265
+ return lines;
253
266
  }
254
267
 
255
268
  private renderHeader(view: HandoffBoardView, width: number): string {
@@ -264,7 +277,14 @@ export class HandoffBoard implements Focusable {
264
277
  return fitLine(`${title}${" ".repeat(gap)}${count}`, width);
265
278
  }
266
279
 
267
- private renderTabs(): string {
280
+ private renderTabs(row: number, width: number): string {
281
+ this.controlHits.set(
282
+ row,
283
+ [
284
+ { id: "tab:subagents", start: 0, end: 9, run: () => this.setTab("subagents") },
285
+ { id: "tab:user-sessions", start: 11, end: 24, run: () => this.setTab("user-sessions") },
286
+ ].filter((hit) => hit.end <= width),
287
+ );
268
288
  const subagents =
269
289
  this.tab === "subagents"
270
290
  ? this.theme.fg("accent", this.theme.bold("Subagents"))
@@ -273,10 +293,22 @@ export class HandoffBoard implements Focusable {
273
293
  this.tab === "user-sessions"
274
294
  ? this.theme.fg("accent", this.theme.bold("User sessions"))
275
295
  : this.theme.fg("dim", "User sessions");
276
- return `${subagents} ${userSessions}`;
296
+ const shownSubagents =
297
+ width >= 9 && this.legendPointer.pressedId === "tab:subagents"
298
+ ? this.theme.bg("selectedBg", subagents)
299
+ : subagents;
300
+ const shownUserSessions =
301
+ width >= 24 && this.legendPointer.pressedId === "tab:user-sessions"
302
+ ? this.theme.bg("selectedBg", userSessions)
303
+ : userSessions;
304
+ return `${shownSubagents} ${shownUserSessions}`;
277
305
  }
278
306
 
279
- private renderSubagentRows(view: HandoffSubagentsView, width: number): string[] {
307
+ private renderSubagentRows(
308
+ view: HandoffSubagentsView,
309
+ width: number,
310
+ firstRow: number,
311
+ ): string[] {
280
312
  const rows = view.rows;
281
313
  const statusWidth = 11;
282
314
  const ageWidth = 4;
@@ -298,6 +330,7 @@ export class HandoffBoard implements Focusable {
298
330
  ...rows.slice(start, start + ROW_VIEWPORT_SIZE).map((entry, visibleIndex) => {
299
331
  const index = start + visibleIndex;
300
332
  const selected = index === this.selectedByTab.subagents;
333
+ this.recordRowHit(firstRow + visibleIndex, view.tab, index, width);
301
334
  const indent = " ".repeat(Math.max(0, entry.depth - 1));
302
335
  const dot = this.theme.fg(stateColor(entry.status), stateDot(entry.status));
303
336
  const first = `${indent}${dot} ${entry.title}`;
@@ -320,7 +353,7 @@ export class HandoffBoard implements Focusable {
320
353
  ];
321
354
  }
322
355
 
323
- private renderUserSessionRows(view: UserSessionsView, width: number): string[] {
356
+ private renderUserSessionRows(view: UserSessionsView, width: number, firstRow: number): string[] {
324
357
  const rows = view.rows;
325
358
  const statusWidth = 11;
326
359
  const ageWidth = 4;
@@ -342,6 +375,7 @@ export class HandoffBoard implements Focusable {
342
375
  ...rows.slice(start, start + ROW_VIEWPORT_SIZE).map((entry, visibleIndex) => {
343
376
  const index = start + visibleIndex;
344
377
  const selected = index === this.selectedByTab["user-sessions"];
378
+ this.recordRowHit(firstRow + visibleIndex, view.tab, index, width);
345
379
  const line = gridRow(
346
380
  [
347
381
  { text: entry.title, width: firstWidth },
@@ -365,6 +399,11 @@ export class HandoffBoard implements Focusable {
365
399
  ];
366
400
  }
367
401
 
402
+ private recordRowHit(row: number, tab: HandoffBoardTab, index: number, width: number): void {
403
+ const sessionId = this.sessionIdAt(tab, index);
404
+ if (sessionId) this.rowHits.set(row, { tab, sessionId, end: width + 2 });
405
+ }
406
+
368
407
  private renderDetails(view: HandoffBoardView, width: number): string[] {
369
408
  const lines = this.detailLines(view);
370
409
  const innerWidth = Math.max(1, width - 2);
@@ -404,44 +443,117 @@ export class HandoffBoard implements Focusable {
404
443
  return view.details.map((detail) => detailLine(this.theme, detail.label, detail.value));
405
444
  }
406
445
 
407
- private renderFooter(view: HandoffBoardView, width: number): string {
446
+ private renderFooter(view: HandoffBoardView, width: number): LegendLine {
408
447
  if (this.busy) {
409
- return this.theme.fg("dim", "Working…");
448
+ return { text: this.theme.fg("dim", "Working…"), hits: [] };
410
449
  }
411
450
  if (this.confirmingStopSessionId) {
451
+ const sessionId = this.confirmingStopSessionId;
412
452
  const prefix = "Stop “";
413
453
  const suffix = "”? x confirm · esc cancel";
414
454
  const titleWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(suffix));
415
455
  const title = truncateToWidth(view.action?.subagent?.title ?? "subagent", titleWidth, "…");
416
- return this.theme.fg("warning", `${prefix}${title}${suffix}`);
456
+ const question = `${prefix}${title}”? `;
457
+ const legend = layoutLegend(
458
+ this.theme,
459
+ [
460
+ {
461
+ key: "x",
462
+ description: "confirm",
463
+ run: this.guardAction(view, () => this.handleStop()),
464
+ },
465
+ {
466
+ key: "esc",
467
+ description: "cancel",
468
+ run: () => {
469
+ if (this.confirmingStopSessionId !== sessionId) return;
470
+ this.clearTransientState();
471
+ this.requestRender();
472
+ },
473
+ },
474
+ ],
475
+ {
476
+ width: Math.max(0, width - visibleWidth(question)),
477
+ separator: " · ",
478
+ pressedId: this.legendPointer.pressedId,
479
+ },
480
+ );
481
+ return {
482
+ text: this.theme.fg("warning", `${question}${legend.text}`),
483
+ hits: legend.hits.map((hit) => ({
484
+ ...hit,
485
+ start: hit.start + visibleWidth(question),
486
+ end: hit.end + visibleWidth(question),
487
+ })),
488
+ };
417
489
  }
418
490
  if (this.status) {
419
- return this.theme.fg("dim", this.status);
491
+ return { text: this.theme.fg("dim", this.status), hits: [] };
420
492
  }
421
493
 
422
494
  const action = view.action;
423
- const parts = [this.hint("<>", "tab"), this.hint("↑↓", "select")];
495
+ const items: LegendItem[] = [
496
+ { key: "<>", description: "tab" },
497
+ { key: "↑↓", description: "select" },
498
+ ];
424
499
  if (action?.canStop) {
425
- parts.push(this.hint("x", "stop"));
500
+ items.push({
501
+ key: "x",
502
+ description: "stop",
503
+ run: this.guardAction(view, () => this.handleStop()),
504
+ });
426
505
  }
427
506
  if (action?.observeCommand) {
428
- parts.push(this.hint("a", "copy observe"));
507
+ items.push({
508
+ key: "a",
509
+ description: "copy observe",
510
+ run: this.guardAction(view, () => this.handleCopyObserve()),
511
+ });
429
512
  }
430
513
  if (action?.resumeCommand) {
431
- parts.push(this.hint("c", "copy resume"));
514
+ items.push({
515
+ key: "c",
516
+ description: "copy resume",
517
+ run: this.guardAction(view, () => this.handleCopyResume()),
518
+ });
432
519
  }
433
- parts.push(this.hint("esc", "close"));
520
+ items.push({
521
+ key: "esc",
522
+ description: "close",
523
+ run: () => {
524
+ this.clearTransientState();
525
+ this.done(undefined);
526
+ },
527
+ });
434
528
 
435
- const left = parts.join(" ");
436
- const position = view.rows.length
437
- ? this.theme.fg("dim", `${this.selectedByTab[this.tab] + 1} of ${view.rows.length}`)
438
- : "";
439
- const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(position));
440
- return fitLine(`${left}${" ".repeat(gap)}${position}`, width);
529
+ return layoutLegend(this.theme, items, {
530
+ width,
531
+ pressedId: this.legendPointer.pressedId,
532
+ trailing: view.rows.length
533
+ ? `${this.selectedByTab[this.tab] + 1} of ${view.rows.length}`
534
+ : "",
535
+ });
441
536
  }
442
537
 
443
- private hint(key: string, description: string): string {
444
- return this.theme.fg("dim", key) + this.theme.fg("muted", ` ${description}`);
538
+ private guardAction(view: HandoffBoardView, run: () => void): () => void {
539
+ const sessionId = this.sessionIdAt(view.tab, this.selectedByTab[view.tab]);
540
+ const confirming = this.confirmingStopSessionId;
541
+ return () => {
542
+ if (
543
+ this.tab !== view.tab ||
544
+ this.sessionIdAt(this.tab, this.selectedByTab[this.tab]) !== sessionId
545
+ )
546
+ return;
547
+ if (this.confirmingStopSessionId !== confirming) return;
548
+ const action = this.currentView().action;
549
+ if (
550
+ action?.canStop !== view.action?.canStop ||
551
+ action?.observeCommand !== view.action?.observeCommand ||
552
+ action?.resumeCommand !== view.action?.resumeCommand
553
+ )
554
+ return;
555
+ run();
556
+ };
445
557
  }
446
558
 
447
559
  private setTab(tab: HandoffBoardTab): void {
@@ -31,6 +31,10 @@ import { buildHandoffLaunchSchema, buildHandoffPromptGuidelines } from "./tool-s
31
31
  import { buildHandoffToolView } from "./tool-view-model.ts";
32
32
  import { formatHandoffError } from "./ui.ts";
33
33
 
34
+ const SESSION_TOKEN_PROMPT_SECTION = "pi_sessions_session_token";
35
+ const SESSION_TOKEN_PROMPT =
36
+ "When the user references @session:<uuid>, treat it as a session token. If you call session_ask, pass only the UUID value, not the @session: prefix.";
37
+
34
38
  interface HandoffToolRendererState {
35
39
  callComponent?: HandoffToolComponent | undefined;
36
40
  }
@@ -196,11 +200,7 @@ export function installHandoff(
196
200
  (await splitBackend.identifyTerminalId(ctx.cwd)) ?? identifiedGhosttyTerminalId;
197
201
  }
198
202
 
199
- return {
200
- systemPrompt:
201
- event.systemPrompt +
202
- "\n\nWhen the user references @session:<uuid>, treat it as a session token. If you call session_ask, pass only the UUID value, not the @session: prefix.",
203
- };
203
+ event.systemPromptOptions.sections[SESSION_TOKEN_PROMPT_SECTION] = SESSION_TOKEN_PROMPT;
204
204
  });
205
205
 
206
206
  return {