pi-sessions 0.7.2 → 0.9.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 (104) hide show
  1. package/README.md +71 -37
  2. package/extensions/pi-sessions.ts +125 -0
  3. package/extensions/session-ask/agent.ts +34 -14
  4. package/extensions/{session-ask.ts → session-ask/install.ts} +73 -90
  5. package/extensions/session-ask/navigate.ts +3 -1
  6. package/extensions/session-ask/presenter.ts +23 -0
  7. package/extensions/session-ask/renderer.ts +63 -0
  8. package/extensions/session-ask/tool-contract.ts +27 -0
  9. package/extensions/session-ask/view-model.ts +18 -0
  10. package/extensions/session-auto-title/generate.ts +18 -18
  11. package/extensions/{session-auto-title.ts → session-auto-title/install.ts} +67 -46
  12. package/extensions/session-auto-title/model.ts +25 -17
  13. package/extensions/session-auto-title/retitle.ts +26 -12
  14. package/extensions/session-auto-title/wizard.ts +12 -6
  15. package/extensions/session-handoff/board-view-model.ts +181 -0
  16. package/extensions/session-handoff/board.ts +663 -0
  17. package/extensions/session-handoff/bootstrap.ts +174 -0
  18. package/extensions/session-handoff/extract.ts +101 -122
  19. package/extensions/session-handoff/install.ts +225 -0
  20. package/extensions/session-handoff/kickoff.ts +92 -0
  21. package/extensions/session-handoff/launch/backend.ts +16 -0
  22. package/extensions/session-handoff/launch/deferred.ts +20 -0
  23. package/extensions/session-handoff/launch/ghostty.ts +99 -0
  24. package/extensions/session-handoff/launch/resolution.ts +57 -0
  25. package/extensions/session-handoff/launch/shell.ts +7 -0
  26. package/extensions/session-handoff/launch/tmux.ts +31 -0
  27. package/extensions/session-handoff/launch-options.ts +61 -0
  28. package/extensions/session-handoff/launch-target.ts +91 -0
  29. package/extensions/session-handoff/metadata.ts +152 -63
  30. package/extensions/session-handoff/model.ts +58 -0
  31. package/extensions/session-handoff/query.ts +0 -1
  32. package/extensions/session-handoff/receipt.ts +96 -0
  33. package/extensions/session-handoff/review.ts +2 -28
  34. package/extensions/session-handoff/spawn.ts +76 -181
  35. package/extensions/session-handoff/strong-modal.ts +13 -0
  36. package/extensions/session-handoff/tool-contract.ts +12 -0
  37. package/extensions/session-handoff/tool-presenter.ts +35 -0
  38. package/extensions/session-handoff/tool-renderer.ts +66 -0
  39. package/extensions/session-handoff/tool-schema.ts +54 -0
  40. package/extensions/session-handoff/tool-view-model.ts +49 -0
  41. package/extensions/session-handoff/tool.ts +206 -0
  42. package/extensions/session-handoff/ui.ts +66 -0
  43. package/extensions/session-hooks/install.ts +75 -0
  44. package/extensions/{session-index.ts → session-index/install.ts} +9 -7
  45. package/extensions/session-messaging/broker/process.ts +21 -28
  46. package/extensions/session-messaging/install.ts +78 -0
  47. package/extensions/session-messaging/pi/cancel-session-presenter.ts +64 -0
  48. package/extensions/session-messaging/pi/incoming-message-presenter.ts +32 -0
  49. package/extensions/session-messaging/pi/incoming-message-view-model.ts +20 -0
  50. package/extensions/session-messaging/pi/incoming-runtime.ts +27 -23
  51. package/extensions/session-messaging/pi/message-contracts.ts +32 -0
  52. package/extensions/session-messaging/pi/renderer.ts +19 -12
  53. package/extensions/session-messaging/pi/send-message-presenter.ts +39 -0
  54. package/extensions/session-messaging/pi/send-message-view-model.ts +54 -0
  55. package/extensions/session-messaging/pi/service.ts +262 -81
  56. package/extensions/session-messaging/pi/tools.ts +184 -40
  57. package/extensions/session-search/extract.ts +60 -14
  58. package/extensions/session-search/hooks.ts +10 -13
  59. package/extensions/session-search/install.ts +373 -0
  60. package/extensions/session-search/renderer.ts +195 -0
  61. package/extensions/session-search/tool-contract.ts +51 -0
  62. package/extensions/shared/composition.ts +20 -0
  63. package/extensions/shared/errors.ts +3 -0
  64. package/extensions/shared/model-resolution.ts +73 -0
  65. package/extensions/shared/model-runtime.ts +33 -0
  66. package/extensions/shared/model.ts +7 -10
  67. package/extensions/shared/rendering/collapsible-text.ts +89 -0
  68. package/extensions/shared/rendering/expandable-content-layout.ts +84 -0
  69. package/extensions/shared/rendering/theme.ts +5 -0
  70. package/extensions/shared/session-broker/client.ts +30 -29
  71. package/extensions/shared/session-broker/protocol.ts +128 -10
  72. package/extensions/shared/session-index/common.ts +5 -5
  73. package/extensions/shared/session-index/lineage.ts +0 -3
  74. package/extensions/shared/session-index/schema.ts +0 -1
  75. package/extensions/shared/session-index/scoring.ts +1 -2
  76. package/extensions/shared/session-index/search.ts +11 -4
  77. package/extensions/shared/session-index/store.ts +4 -9
  78. package/extensions/shared/settings.ts +80 -38
  79. package/extensions/shared/text.ts +5 -0
  80. package/extensions/shared/thinking-levels.ts +22 -0
  81. package/extensions/shared/tmux.ts +205 -0
  82. package/extensions/subagents/cancel.ts +111 -0
  83. package/extensions/subagents/classify.ts +52 -0
  84. package/extensions/subagents/install.ts +344 -0
  85. package/extensions/subagents/launch-target.ts +75 -0
  86. package/extensions/subagents/ledger.ts +217 -0
  87. package/extensions/subagents/reconcile.ts +386 -0
  88. package/extensions/subagents/report-message-presenter.ts +36 -0
  89. package/extensions/subagents/report-message-renderer.ts +21 -0
  90. package/extensions/subagents/report-message-view-model.ts +29 -0
  91. package/extensions/subagents/report.ts +159 -0
  92. package/extensions/subagents/roster.ts +262 -0
  93. package/extensions/subagents/wake.ts +173 -0
  94. package/images/handoff-board-subagents.png +0 -0
  95. package/images/handoff-board-user-sessions.png +0 -0
  96. package/images/handoff-subagent-report.png +0 -0
  97. package/images/session-handoff-tool.png +0 -0
  98. package/package.json +10 -16
  99. package/extensions/session-handoff.ts +0 -741
  100. package/extensions/session-hooks.ts +0 -75
  101. package/extensions/session-messaging/pi/message-view.ts +0 -131
  102. package/extensions/session-messaging.ts +0 -30
  103. package/extensions/session-search.ts +0 -419
  104. package/extensions/shared/session-broker/active.ts +0 -26
@@ -0,0 +1,663 @@
1
+ import {
2
+ copyToClipboard,
3
+ type ExtensionCommandContext,
4
+ type SessionEntry,
5
+ type Theme,
6
+ type ThemeColor,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { type Focusable, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { formatCompactRelativeTime } from "../shared/time.ts";
10
+ import type { SubagentState } from "../subagents/classify.ts";
11
+ import type { SubagentRoster } from "../subagents/roster.ts";
12
+ import {
13
+ buildHandoffBoardView,
14
+ type HandoffBoardSnapshot,
15
+ type HandoffBoardTab,
16
+ type HandoffBoardView,
17
+ type HandoffSubagentsView,
18
+ type UserSessionEntry,
19
+ type UserSessionStatus,
20
+ type UserSessionsView,
21
+ } from "./board-view-model.ts";
22
+ import { HANDOFF_BOOTSTRAP_PENDING_CUSTOM_TYPE } from "./metadata.ts";
23
+ import { parseHandoffLaunchReceiptEntry } from "./receipt.ts";
24
+
25
+ const BOARD_WIDTH = 86;
26
+ const ROW_VIEWPORT_SIZE = 8;
27
+ const STATUS_DURATION_MS = 3_000;
28
+
29
+ export type { HandoffBoardSnapshot, UserSessionEntry } from "./board-view-model.ts";
30
+
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;
36
+ }
37
+
38
+ interface HandoffBoardActions {
39
+ refresh(): Promise<HandoffBoardSnapshot>;
40
+ stop(sessionId: string): Promise<void>;
41
+ copy(text: string): Promise<void>;
42
+ }
43
+
44
+ export async function openHandoffBoard(
45
+ ctx: ExtensionCommandContext,
46
+ services: HandoffBoardServices,
47
+ ): Promise<void> {
48
+ const load = async (): Promise<HandoffBoardSnapshot> =>
49
+ loadHandoffBoardSnapshot(ctx.sessionManager.getEntries(), services);
50
+ const initial = await load();
51
+
52
+ await ctx.ui.custom<void>(
53
+ (tui, theme, _keybindings, done) =>
54
+ new HandoffBoard(
55
+ theme,
56
+ initial,
57
+ {
58
+ refresh: load,
59
+ async stop(sessionId) {
60
+ if (!services.cancelSubagent) {
61
+ throw new Error("Subagent cancellation is unavailable.");
62
+ }
63
+ await services.cancelSubagent(sessionId);
64
+ },
65
+ copy: copyToClipboard,
66
+ },
67
+ done,
68
+ () => tui.requestRender(),
69
+ ),
70
+ {
71
+ overlay: true,
72
+ overlayOptions: { anchor: "center", width: 86, margin: 1 },
73
+ },
74
+ );
75
+ }
76
+
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
+ export class HandoffBoard implements Focusable {
159
+ focused = false;
160
+ private tab: HandoffBoardTab = "subagents";
161
+ private selectedByTab: Record<HandoffBoardTab, number> = { subagents: 0, "user-sessions": 0 };
162
+ private confirmingStopSessionId: string | undefined;
163
+ private status: string | undefined;
164
+ private statusGeneration = 0;
165
+ private busy = false;
166
+
167
+ constructor(
168
+ private readonly theme: Theme,
169
+ private snapshot: HandoffBoardSnapshot,
170
+ private readonly actions: HandoffBoardActions,
171
+ private readonly done: (value: undefined) => void,
172
+ private readonly requestRender: () => void = () => {},
173
+ private readonly now: () => number = Date.now,
174
+ private readonly schedule: (callback: () => void, delayMs: number) => void = scheduleTimeout,
175
+ ) {}
176
+
177
+ invalidate(): void {}
178
+
179
+ handleInput(data: string): void {
180
+ if (matchesKey(data, "q")) {
181
+ this.clearTransientState();
182
+ this.done(undefined);
183
+ return;
184
+ }
185
+ if (matchesKey(data, "escape")) {
186
+ if (this.confirmingStopSessionId) {
187
+ this.confirmingStopSessionId = undefined;
188
+ this.clearStatus();
189
+ this.requestRender();
190
+ return;
191
+ }
192
+ this.clearStatus();
193
+ this.done(undefined);
194
+ return;
195
+ }
196
+ if (this.busy) {
197
+ return;
198
+ }
199
+ if (this.status) {
200
+ this.clearStatus();
201
+ this.requestRender();
202
+ }
203
+ if (matchesKey(data, "left") || matchesKey(data, "h")) {
204
+ this.setTab("subagents");
205
+ } else if (matchesKey(data, "right") || matchesKey(data, "l")) {
206
+ this.setTab("user-sessions");
207
+ } else if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
208
+ this.setTab(this.tab === "subagents" ? "user-sessions" : "subagents");
209
+ } else if (matchesKey(data, "up") || matchesKey(data, "k")) {
210
+ this.moveSelection(-1);
211
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
212
+ this.moveSelection(1);
213
+ } else if (data === "x") {
214
+ this.handleStop();
215
+ } else if (data === "a") {
216
+ this.handleCopyObserve();
217
+ } else if (data === "c") {
218
+ this.handleCopyResume();
219
+ } else if (data === "r") {
220
+ this.runAction(async () => {
221
+ this.snapshot = await this.actions.refresh();
222
+ this.clampSelection();
223
+ this.setStatus("Refreshed");
224
+ });
225
+ }
226
+ }
227
+
228
+ render(width: number): string[] {
229
+ const modalWidth = Math.max(20, Math.min(width, BOARD_WIDTH));
230
+ const innerWidth = modalWidth - 2;
231
+ const bodyWidth = innerWidth - 2;
232
+ const border = (text: string) => this.theme.fg("border", text);
233
+ const row = (content = "") => `${border("│")} ${fitCell(content, bodyWidth)} ${border("│")}`;
234
+ const view = this.currentView();
235
+ const rows =
236
+ view.tab === "subagents"
237
+ ? this.renderSubagentRows(view, bodyWidth)
238
+ : this.renderUserSessionRows(view, bodyWidth);
239
+
240
+ return [
241
+ border(`╭${"─".repeat(innerWidth)}╮`),
242
+ row(this.renderHeader(view, bodyWidth)),
243
+ 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
+ ];
253
+ }
254
+
255
+ private renderHeader(view: HandoffBoardView, width: number): string {
256
+ const title = this.theme.fg("accent", this.theme.bold("Handoffs"));
257
+ const count = this.theme.fg(
258
+ "muted",
259
+ view.tab === "subagents"
260
+ ? `${view.rows.length} on branch`
261
+ : `${view.rows.length} user sessions`,
262
+ );
263
+ const gap = Math.max(1, width - visibleWidth(title) - visibleWidth(count));
264
+ return fitLine(`${title}${" ".repeat(gap)}${count}`, width);
265
+ }
266
+
267
+ private renderTabs(): string {
268
+ const subagents =
269
+ this.tab === "subagents"
270
+ ? this.theme.fg("accent", this.theme.bold("Subagents"))
271
+ : this.theme.fg("dim", "Subagents");
272
+ const userSessions =
273
+ this.tab === "user-sessions"
274
+ ? this.theme.fg("accent", this.theme.bold("User sessions"))
275
+ : this.theme.fg("dim", "User sessions");
276
+ return `${subagents} ${userSessions}`;
277
+ }
278
+
279
+ private renderSubagentRows(view: HandoffSubagentsView, width: number): string[] {
280
+ const rows = view.rows;
281
+ const statusWidth = 11;
282
+ const ageWidth = 4;
283
+ const firstWidth = Math.max(8, width - 2 - statusWidth - ageWidth - 4);
284
+ const header = gridRow(
285
+ [
286
+ { text: "Subagent", width: firstWidth, color: "muted" },
287
+ { text: "Status", width: statusWidth, color: "muted" },
288
+ { text: "Age", width: ageWidth, align: "right", color: "muted" },
289
+ ],
290
+ this.theme,
291
+ );
292
+ if (rows.length === 0) {
293
+ return [header, this.theme.fg("dim", " No subagents on the active branch")];
294
+ }
295
+ const start = viewportStart(rows.length, this.selectedByTab.subagents);
296
+ return [
297
+ header,
298
+ ...rows.slice(start, start + ROW_VIEWPORT_SIZE).map((entry, visibleIndex) => {
299
+ const index = start + visibleIndex;
300
+ const selected = index === this.selectedByTab.subagents;
301
+ const indent = " ".repeat(Math.max(0, entry.depth - 1));
302
+ const dot = this.theme.fg(stateColor(entry.status), stateDot(entry.status));
303
+ const first = `${indent}${dot} ${entry.title}`;
304
+ const line = gridRow(
305
+ [
306
+ { text: first, width: firstWidth },
307
+ { text: entry.status, width: statusWidth, color: stateColor(entry.status) },
308
+ {
309
+ text: formatAge(entry.timestamp, this.now()),
310
+ width: ageWidth,
311
+ align: "right",
312
+ color: "dim",
313
+ },
314
+ ],
315
+ this.theme,
316
+ selected,
317
+ );
318
+ return selected ? this.theme.bg("selectedBg", line) : line;
319
+ }),
320
+ ];
321
+ }
322
+
323
+ private renderUserSessionRows(view: UserSessionsView, width: number): string[] {
324
+ const rows = view.rows;
325
+ const statusWidth = 11;
326
+ const ageWidth = 4;
327
+ const firstWidth = Math.max(8, width - 2 - statusWidth - ageWidth - 4);
328
+ const header = gridRow(
329
+ [
330
+ { text: "Session", width: firstWidth, color: "muted" },
331
+ { text: "Status", width: statusWidth, color: "muted" },
332
+ { text: "Age", width: ageWidth, align: "right", color: "muted" },
333
+ ],
334
+ this.theme,
335
+ );
336
+ if (rows.length === 0) {
337
+ return [header, this.theme.fg("dim", " No user sessions recorded")];
338
+ }
339
+ const start = viewportStart(rows.length, this.selectedByTab["user-sessions"]);
340
+ return [
341
+ header,
342
+ ...rows.slice(start, start + ROW_VIEWPORT_SIZE).map((entry, visibleIndex) => {
343
+ const index = start + visibleIndex;
344
+ const selected = index === this.selectedByTab["user-sessions"];
345
+ const line = gridRow(
346
+ [
347
+ { text: entry.title, width: firstWidth },
348
+ {
349
+ text: entry.status,
350
+ width: statusWidth,
351
+ color: userSessionStatusColor(entry.status),
352
+ },
353
+ {
354
+ text: formatAge(entry.timestamp, this.now()),
355
+ width: ageWidth,
356
+ align: "right",
357
+ color: "dim",
358
+ },
359
+ ],
360
+ this.theme,
361
+ selected,
362
+ );
363
+ return selected ? this.theme.bg("selectedBg", line) : line;
364
+ }),
365
+ ];
366
+ }
367
+
368
+ private renderDetails(view: HandoffBoardView, width: number): string[] {
369
+ const lines = this.detailLines(view);
370
+ const innerWidth = Math.max(1, width - 2);
371
+ const label = " Details ";
372
+ const top = `┌─${label}${"─".repeat(Math.max(0, innerWidth - label.length - 1))}┐`;
373
+ const bottom = `└${"─".repeat(innerWidth)}┘`;
374
+ let color: ThemeColor = "dim";
375
+ if (view.tab === "subagents") {
376
+ const selected = view.rows[this.selectedByTab.subagents];
377
+ if (selected) {
378
+ color = stateColor(selected.status);
379
+ }
380
+ } else {
381
+ const selected = view.rows[this.selectedByTab["user-sessions"]];
382
+ if (selected) {
383
+ color = userSessionStatusColor(selected.status);
384
+ }
385
+ }
386
+ return [
387
+ this.theme.fg(color, fitLine(top, width)),
388
+ ...lines.map((line) => {
389
+ const content = fitCell(line, Math.max(0, innerWidth - 2));
390
+ return `${this.theme.fg(color, "│")} ${content} ${this.theme.fg(color, "│")}`;
391
+ }),
392
+ this.theme.fg(color, fitLine(bottom, width)),
393
+ ];
394
+ }
395
+
396
+ private detailLines(view: HandoffBoardView): string[] {
397
+ if (view.details.length === 0) {
398
+ const message =
399
+ view.tab === "subagents"
400
+ ? "No active-branch subagent selected"
401
+ : "No user session selected";
402
+ return [this.theme.fg("dim", message)];
403
+ }
404
+ return view.details.map((detail) => detailLine(this.theme, detail.label, detail.value));
405
+ }
406
+
407
+ private renderFooter(view: HandoffBoardView, width: number): string {
408
+ if (this.busy) {
409
+ return this.theme.fg("dim", "Working…");
410
+ }
411
+ if (this.confirmingStopSessionId) {
412
+ const prefix = "Stop “";
413
+ const suffix = "”? x confirm · esc cancel";
414
+ const titleWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(suffix));
415
+ const title = truncateToWidth(view.action?.subagent?.title ?? "subagent", titleWidth, "…");
416
+ return this.theme.fg("warning", `${prefix}${title}${suffix}`);
417
+ }
418
+ if (this.status) {
419
+ return this.theme.fg("dim", this.status);
420
+ }
421
+
422
+ const action = view.action;
423
+ const parts = [this.hint("<>", "tab"), this.hint("↑↓", "select")];
424
+ if (action?.canStop) {
425
+ parts.push(this.hint("x", "stop"));
426
+ }
427
+ if (action?.observeCommand) {
428
+ parts.push(this.hint("a", "copy observe"));
429
+ }
430
+ if (action?.resumeCommand) {
431
+ parts.push(this.hint("c", "copy resume"));
432
+ }
433
+ parts.push(this.hint("esc", "close"));
434
+
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);
441
+ }
442
+
443
+ private hint(key: string, description: string): string {
444
+ return this.theme.fg("dim", key) + this.theme.fg("muted", ` ${description}`);
445
+ }
446
+
447
+ private setTab(tab: HandoffBoardTab): void {
448
+ this.tab = tab;
449
+ this.clearTransientState();
450
+ this.clampSelection();
451
+ this.requestRender();
452
+ }
453
+
454
+ private moveSelection(delta: number): void {
455
+ const count =
456
+ this.tab === "subagents" ? this.snapshot.subagents.length : this.snapshot.userSessions.length;
457
+ if (count === 0) {
458
+ return;
459
+ }
460
+ this.selectedByTab[this.tab] = Math.max(
461
+ 0,
462
+ Math.min(count - 1, this.selectedByTab[this.tab] + delta),
463
+ );
464
+ this.clearTransientState();
465
+ this.requestRender();
466
+ }
467
+
468
+ private clampSelection(): void {
469
+ for (const tab of ["subagents", "user-sessions"] as const) {
470
+ const count =
471
+ tab === "subagents" ? this.snapshot.subagents.length : this.snapshot.userSessions.length;
472
+ this.selectedByTab[tab] = Math.max(
473
+ 0,
474
+ Math.min(Math.max(0, count - 1), this.selectedByTab[tab]),
475
+ );
476
+ }
477
+ }
478
+
479
+ private clearTransientState(): void {
480
+ this.confirmingStopSessionId = undefined;
481
+ this.clearStatus();
482
+ }
483
+
484
+ private clearStatus(): void {
485
+ this.status = undefined;
486
+ this.statusGeneration += 1;
487
+ }
488
+
489
+ private setStatus(status: string, dismiss = true): void {
490
+ this.status = status;
491
+ const generation = ++this.statusGeneration;
492
+ if (!dismiss) {
493
+ return;
494
+ }
495
+ this.schedule(() => {
496
+ if (this.statusGeneration !== generation) {
497
+ return;
498
+ }
499
+ this.status = undefined;
500
+ this.statusGeneration += 1;
501
+ this.requestRender();
502
+ }, STATUS_DURATION_MS);
503
+ }
504
+
505
+ private handleStop(): void {
506
+ const action = this.currentView().action;
507
+ if (!action?.canStop || !action.subagent) {
508
+ return;
509
+ }
510
+ if (this.confirmingStopSessionId !== action.subagent.sessionId) {
511
+ this.confirmingStopSessionId = action.subagent.sessionId;
512
+ this.clearStatus();
513
+ this.requestRender();
514
+ return;
515
+ }
516
+ const subagent = action.subagent;
517
+ this.confirmingStopSessionId = undefined;
518
+ this.runAction(async () => {
519
+ await this.actions.stop(subagent.sessionId);
520
+ this.snapshot = await this.actions.refresh();
521
+ this.clampSelection();
522
+ this.setStatus(`Stopped ${subagent.title}`);
523
+ });
524
+ }
525
+
526
+ private handleCopyObserve(): void {
527
+ const command = this.currentView().action?.observeCommand;
528
+ if (!command) {
529
+ return;
530
+ }
531
+ this.runAction(async () => {
532
+ await this.actions.copy(command);
533
+ this.setStatus("Observe command copied");
534
+ });
535
+ }
536
+
537
+ private handleCopyResume(): void {
538
+ const command = this.currentView().action?.resumeCommand;
539
+ if (!command) {
540
+ return;
541
+ }
542
+ this.runAction(async () => {
543
+ await this.actions.copy(command);
544
+ this.setStatus("Resume command copied");
545
+ });
546
+ }
547
+
548
+ private runAction(action: () => Promise<void>): void {
549
+ this.busy = true;
550
+ this.clearStatus();
551
+ this.requestRender();
552
+ void action()
553
+ .catch((error: unknown) => {
554
+ this.setStatus(error instanceof Error ? error.message : String(error), false);
555
+ })
556
+ .finally(() => {
557
+ this.busy = false;
558
+ this.requestRender();
559
+ });
560
+ }
561
+
562
+ private currentView(): HandoffBoardView {
563
+ return buildHandoffBoardView(this.snapshot, this.tab, this.selectedByTab[this.tab], {
564
+ insideTmux: Boolean(process.env.TMUX),
565
+ });
566
+ }
567
+ }
568
+
569
+ function viewportStart(rowCount: number, selectedIndex: number): number {
570
+ if (rowCount <= ROW_VIEWPORT_SIZE || selectedIndex < ROW_VIEWPORT_SIZE) {
571
+ return 0;
572
+ }
573
+ return Math.min(selectedIndex - ROW_VIEWPORT_SIZE + 1, rowCount - ROW_VIEWPORT_SIZE);
574
+ }
575
+
576
+ function scheduleTimeout(callback: () => void, delayMs: number): void {
577
+ const timeout = setTimeout(callback, delayMs);
578
+ timeout.unref();
579
+ }
580
+
581
+ interface GridCell {
582
+ text: string;
583
+ width: number;
584
+ align?: "left" | "right" | undefined;
585
+ color?: ThemeColor | undefined;
586
+ }
587
+
588
+ function gridRow(cells: readonly GridCell[], theme: Theme, selected = false): string {
589
+ const rendered = cells.map((cell) => {
590
+ const content = fitCell(cell.text, cell.width, cell.align);
591
+ return cell.color ? theme.fg(cell.color, content) : content;
592
+ });
593
+ const cursor = selected ? `${theme.fg("accent", "›")} ` : " ";
594
+ return `${cursor}${rendered.join(" ")}`;
595
+ }
596
+
597
+ function detailLine(theme: Theme, label: string, value: string): string {
598
+ return `${theme.fg("muted", label.padEnd(10))}${value}`;
599
+ }
600
+
601
+ function fitCell(value: string, width: number, align: "left" | "right" = "left"): string {
602
+ if (width <= 0) {
603
+ return "";
604
+ }
605
+ const truncated = truncateToWidth(value, width, "…");
606
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(truncated)));
607
+ return align === "right" ? `${padding}${truncated}` : `${truncated}${padding}`;
608
+ }
609
+
610
+ function fitLine(value: string, width: number): string {
611
+ return width <= 0 ? "" : truncateToWidth(value, width, "");
612
+ }
613
+
614
+ function formatAge(timestamp: string, now: number): string {
615
+ return formatCompactRelativeTime(timestamp, now) ?? "—";
616
+ }
617
+
618
+ function stateDot(state: SubagentState): string {
619
+ switch (state) {
620
+ case "starting":
621
+ case "busy":
622
+ case "active":
623
+ return "●";
624
+ case "completed":
625
+ case "stopping":
626
+ case "stopped":
627
+ case "suspended":
628
+ case "interrupted":
629
+ case "unknown":
630
+ return "○";
631
+ }
632
+ }
633
+
634
+ function stateColor(state: SubagentState): "success" | "error" | "warning" | "dim" {
635
+ switch (state) {
636
+ case "busy":
637
+ case "active":
638
+ return "success";
639
+ case "starting":
640
+ case "interrupted":
641
+ case "unknown":
642
+ return "warning";
643
+ case "stopped":
644
+ case "stopping":
645
+ return "error";
646
+ case "completed":
647
+ case "suspended":
648
+ return "dim";
649
+ }
650
+ }
651
+
652
+ function userSessionStatusColor(state: UserSessionStatus): "success" | "warning" | "muted" {
653
+ switch (state) {
654
+ case "live":
655
+ return "success";
656
+ case "starting":
657
+ case "unknown":
658
+ return "warning";
659
+ case "closed":
660
+ case "ready":
661
+ return "muted";
662
+ }
663
+ }