@signalridge/pi-sessions 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts ADDED
@@ -0,0 +1,581 @@
1
+ import { isAbsolute } from "node:path";
2
+ import type { ExtensionContext, KeybindingsManager, SessionInfo, Theme } from "@earendil-works/pi-coding-agent";
3
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ type Component,
6
+ CURSOR_MARKER,
7
+ Editor,
8
+ type Focusable,
9
+ getKeybindings,
10
+ Input,
11
+ Key,
12
+ matchesKey,
13
+ type OverlayHandle,
14
+ sliceByColumn,
15
+ type TUI,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ wrapTextWithAnsi,
19
+ } from "@earendil-works/pi-tui";
20
+ import { SafeInput } from "./input.js";
21
+ import type { Owner } from "./ownership.js";
22
+ import { label, plain, ROW_LIMIT, redact } from "./text.js";
23
+
24
+ type Surface = { tui: TUI; theme: Theme; keys: KeybindingsManager };
25
+ export type SessionRow = Pick<SessionInfo, "id" | "path" | "cwd" | "name" | "messageCount"> & { modified: string };
26
+ /** Discovery metadata is untrusted even when returned by the native SDK. */
27
+ export function isSessionInfo(value: unknown): value is SessionInfo {
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
29
+ const info = value as Record<string, unknown>;
30
+ return (
31
+ typeof info.id === "string" &&
32
+ info.id.length > 0 &&
33
+ info.id.length <= 4096 &&
34
+ typeof info.path === "string" &&
35
+ info.path.length <= 8192 &&
36
+ isAbsolute(info.path) &&
37
+ typeof info.cwd === "string" &&
38
+ info.cwd.length <= 8192 &&
39
+ (info.cwd === "" || isAbsolute(info.cwd)) &&
40
+ (info.name === undefined || typeof info.name === "string") &&
41
+ (info.parentSessionPath === undefined || typeof info.parentSessionPath === "string") &&
42
+ typeof info.firstMessage === "string" &&
43
+ typeof info.allMessagesText === "string" &&
44
+ typeof info.messageCount === "number" &&
45
+ Number.isSafeInteger(info.messageCount) &&
46
+ info.messageCount >= 0 &&
47
+ info.created instanceof Date &&
48
+ Number.isFinite(info.created.getTime()) &&
49
+ info.modified instanceof Date &&
50
+ Number.isFinite(info.modified.getTime())
51
+ );
52
+ }
53
+ export function rowsFrom(sessions: readonly unknown[]): SessionRow[] {
54
+ return sessions
55
+ .filter(isSessionInfo)
56
+ .slice(0, ROW_LIMIT)
57
+ .map((s) => ({
58
+ id: s.id,
59
+ path: s.path,
60
+ cwd: s.cwd,
61
+ name: label(redact(s.name ?? s.firstMessage)),
62
+ messageCount: s.messageCount,
63
+ modified: s.modified.toISOString(),
64
+ }));
65
+ }
66
+ export function rowKey(row: Pick<SessionRow, "id" | "path">): string {
67
+ return `${row.id}\0${row.path}`;
68
+ }
69
+
70
+ /** Renderer output only: inputs must be plain BEFORE entering native components.
71
+ * Keep just native software-cursor inverse/reset and the IME position marker,
72
+ * never arbitrary SGR, OSC, erase or cursor-motion instructions. */
73
+ export function safeLines(lines: string[], width: number): string[] {
74
+ const columns = Math.max(1, width);
75
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
76
+ return lines
77
+ .flatMap((line) => line.split("\n"))
78
+ .map((line) => {
79
+ const safe = line
80
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: exact renderer-owned SGR and public IME marker allowlist
81
+ .split(/(\x1b\[(?:0|7|27)m|\x1b_pi:c\x07)/u)
82
+ .map((part, i) => {
83
+ if (i % 2) return part;
84
+ // A terminal cannot display a two-column glyph in one column. Substitute
85
+ // only the rendered glyph, never the underlying editable value.
86
+ return [...segmenter.segment(plain(part))]
87
+ .map(({ segment }) => (visibleWidth(segment) > columns ? "?" : segment))
88
+ .join("");
89
+ })
90
+ .join("");
91
+ const marker = safe.indexOf(CURSOR_MARKER);
92
+ if (marker < 0) return truncateToWidth(safe, columns, "");
93
+ const before = safe.slice(0, marker);
94
+ const after = safe.slice(marker + CURSOR_MARKER.length);
95
+ // Keep the insertion cell in bounds, including at width=1 and at EOL.
96
+ const start = Math.max(0, visibleWidth(before) - columns + 1);
97
+ const left = sliceByColumn(before, start, columns - 1, true);
98
+ return left + CURSOR_MARKER + truncateToWidth(after, columns - visibleWidth(left), "");
99
+ });
100
+ }
101
+
102
+ function help(text: string, width: number): string[] {
103
+ return wrapTextWithAnsi(plain(text), Math.max(1, width));
104
+ }
105
+
106
+ function resizeNotice(width: number, rows: number): string[] {
107
+ return safeLines(
108
+ ["Resize to review; approval disabled", `${getKeybindings().getKeys("tui.select.cancel").join("/")} cancel`].slice(
109
+ 0,
110
+ Math.max(1, rows),
111
+ ),
112
+ width,
113
+ );
114
+ }
115
+
116
+ class SafeComponent implements Component, Focusable {
117
+ constructor(private readonly child: Component & Partial<Focusable>) {}
118
+ get focused(): boolean {
119
+ return this.child.focused ?? false;
120
+ }
121
+ set focused(value: boolean) {
122
+ this.child.focused = value;
123
+ }
124
+ render(width: number): string[] {
125
+ return safeLines(this.child.render(Math.max(1, width)), width);
126
+ }
127
+ handleInput(data: string): void {
128
+ this.child.handleInput?.(data);
129
+ }
130
+ invalidate(): void {
131
+ this.child.invalidate();
132
+ }
133
+ }
134
+
135
+ /** Never mount a native custom dialog. In Pi 1.0.4 native completion pops the
136
+ * top overlay (possibly foreign), or restores an old main-editor draft.
137
+ * Complete the public factory synchronously to acquire its public TUI, then
138
+ * use only manually owned OverlayHandles. No host fields are read or patched. */
139
+ async function surface(owner: Owner<ExtensionContext>): Promise<Surface> {
140
+ return owner.wait(
141
+ owner.ctx.ui.custom<Surface>(
142
+ (tui, theme, keys, done) => {
143
+ // Native done() pops the current top overlay even before it mounts its
144
+ // component. Supply a synchronous, noncapturing sentinel so this cannot
145
+ // pop a pre-existing foreign overlay. No asynchronous gap is permitted.
146
+ const sentinel = { render: () => [], invalidate() {} };
147
+ tui.showOverlay(sentinel, { nonCapturing: true });
148
+ done({ tui, theme, keys });
149
+ return sentinel;
150
+ },
151
+ { overlay: true },
152
+ ),
153
+ );
154
+ }
155
+
156
+ async function modal<T>(
157
+ owner: Owner<ExtensionContext>,
158
+ build: (
159
+ s: Surface,
160
+ done: (result: T | undefined) => void,
161
+ isFocused: () => boolean,
162
+ ) => Component & Partial<Focusable>,
163
+ ): Promise<T | undefined> {
164
+ const s = await surface(owner);
165
+ owner.check();
166
+ let handle: OverlayHandle | undefined;
167
+ let abort!: () => void;
168
+ return new Promise<T | undefined>((resolve) => {
169
+ let closed = false;
170
+ const close = (result: T | undefined) => {
171
+ if (closed) return;
172
+ closed = true;
173
+ owner.signal.removeEventListener("abort", abort);
174
+ handle?.hide(); // This overlay belongs to us, not to ui.custom().
175
+ resolve(result);
176
+ };
177
+ abort = () => close(undefined);
178
+ owner.signal.addEventListener("abort", abort, { once: true });
179
+ const component = new SafeComponent(build(s, close, () => !closed && handle?.isFocused() === true));
180
+ // Full-terminal bounds make terminal.rows the actual overlay row budget.
181
+ // Each component reserves its own controls before laying out content.
182
+ if (!closed) handle = s.tui.showOverlay(component, { width: "100%", maxHeight: "100%", anchor: "top-left" });
183
+ });
184
+ }
185
+
186
+ class Choice implements Component, Focusable {
187
+ focused = false;
188
+ private selected = 0;
189
+ private scroll = 0;
190
+ private pageSize = 8;
191
+ private lastLineCount = 0;
192
+ private ready = true;
193
+ constructor(
194
+ private readonly title: string,
195
+ private readonly options: string[],
196
+ private readonly done: (value: string | undefined) => void,
197
+ private readonly renderAgain: () => void,
198
+ private readonly terminalRows: () => number,
199
+ private readonly terminalColumns: () => number,
200
+ ) {}
201
+ invalidate(): void {}
202
+ render(width: number): string[] {
203
+ const keys = getKeybindings();
204
+ const controls = help(
205
+ `${keys.getKeys("tui.select.confirm").join("/")} accept · ${keys.getKeys("tui.select.cancel").join("/")} cancel\n${keys.getKeys("tui.select.up").join("/")}/${keys.getKeys("tui.select.down").join("/")} choose · ${keys.getKeys("tui.select.pageUp").join("/")}/${keys.getKeys("tui.select.pageDown").join("/")} scroll`,
206
+ width,
207
+ );
208
+ const rows = Math.max(1, this.terminalRows());
209
+ const available = rows - this.options.length - controls.length;
210
+ this.pageSize = Math.max(1, available);
211
+ this.ready = width >= 12 && available >= 1;
212
+ if (!this.ready) return resizeNotice(width, rows);
213
+ const wrapped = wrapTextWithAnsi(plain(this.title), Math.max(1, width));
214
+ this.lastLineCount = wrapped.length;
215
+ this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, wrapped.length - this.pageSize)));
216
+ return safeLines(
217
+ [
218
+ ...wrapped.slice(this.scroll, this.scroll + this.pageSize),
219
+ ...this.options.map((s, i) => `${i === this.selected ? ">" : " "} ${plain(s)}`),
220
+ ...controls,
221
+ ],
222
+ width,
223
+ );
224
+ }
225
+ handleInput(data: string): void {
226
+ const keys = getKeybindings();
227
+ if (keys.matches(data, "tui.select.cancel")) this.done(undefined);
228
+ else if (keys.matches(data, "tui.select.confirm")) {
229
+ this.render(this.terminalColumns()); // resize may precede the scheduled host render
230
+ if (this.ready) this.done(this.options[this.selected]);
231
+ } else if (keys.matches(data, "tui.select.up"))
232
+ this.selected = (this.selected + this.options.length - 1) % this.options.length;
233
+ else if (keys.matches(data, "tui.select.down")) this.selected = (this.selected + 1) % this.options.length;
234
+ else if (keys.matches(data, "tui.select.pageUp")) this.scroll = Math.max(0, this.scroll - this.pageSize);
235
+ else if (keys.matches(data, "tui.select.pageDown"))
236
+ this.scroll = Math.min(Math.max(0, this.lastLineCount - this.pageSize), this.scroll + this.pageSize);
237
+ this.renderAgain();
238
+ }
239
+ }
240
+
241
+ export class Picker implements Component, Focusable {
242
+ private readonly input = new Input({ prompt: "" });
243
+ private readonly safeInput = new SafeInput(({ kind, data }) => this.handleSafeInput(data, kind === "paste"));
244
+ private ready = true;
245
+ private lastWidth = 80;
246
+ private pageSize = 8;
247
+ private rows: SessionRow[] = [];
248
+ private selectedKey: string | undefined;
249
+ private progress = "Loading sessions…";
250
+ private _focused = false;
251
+ get focused(): boolean {
252
+ return this._focused;
253
+ }
254
+ set focused(value: boolean) {
255
+ this._focused = value;
256
+ this.input.focused = value;
257
+ }
258
+ constructor(
259
+ private readonly done: (row: SessionRow | undefined) => void,
260
+ private readonly renderAgain: () => void,
261
+ private readonly terminalRows: () => number = () => 24,
262
+ private readonly terminalColumns: () => number = () => this.lastWidth,
263
+ ) {}
264
+ invalidate(): void {
265
+ this.input.invalidate();
266
+ }
267
+ update(rows: SessionRow[], loaded: number, total: number, complete = false): void {
268
+ this.rows = rows;
269
+ this.progress = `${complete ? "Ready" : "Loading"}: ${loaded}/${total}; showing at most ${ROW_LIMIT}`;
270
+ if (!this.rows.some((row) => rowKey(row) === this.selectedKey))
271
+ this.selectedKey = this.filtered()[0] && rowKey(this.filtered()[0]);
272
+ this.renderAgain();
273
+ }
274
+ private filtered(): SessionRow[] {
275
+ const query = plain(this.input.getValue()).toLocaleLowerCase();
276
+ return this.rows.filter((row) => `${row.name} ${row.id} ${row.cwd}`.toLocaleLowerCase().includes(query));
277
+ }
278
+ render(width: number): string[] {
279
+ this.lastWidth = width;
280
+ const filtered = this.filtered();
281
+ const index = Math.max(
282
+ 0,
283
+ filtered.findIndex((r) => rowKey(r) === this.selectedKey),
284
+ );
285
+ const keys = getKeybindings();
286
+ const controls = help(
287
+ `${keys.getKeys("tui.select.confirm").join("/")} preview · ${keys.getKeys("tui.select.cancel").join("/")} cancel\nType filter · ${keys.getKeys("tui.select.up").join("/")}/${keys.getKeys("tui.select.down").join("/")} select · ${keys.getKeys("tui.select.pageUp").join("/")}/${keys.getKeys("tui.select.pageDown").join("/")} page`,
288
+ width,
289
+ );
290
+ const rows = Math.max(1, this.terminalRows());
291
+ const pageSize = rows - 3 - controls.length;
292
+ this.pageSize = Math.max(1, pageSize);
293
+ this.ready = width >= 12 && pageSize >= 1;
294
+ if (!this.ready) return resizeNotice(width, rows);
295
+ const start = Math.max(0, Math.min(index - Math.floor(pageSize / 2), filtered.length - pageSize));
296
+ return safeLines(
297
+ [
298
+ "Sessions (history and workspace metadata only)",
299
+ this.progress,
300
+ ...this.input.render(Math.max(1, width)),
301
+ ...filtered
302
+ .slice(start, start + pageSize)
303
+ .map((r) =>
304
+ plain(`${rowKey(r) === this.selectedKey ? ">" : " "} ${r.name || "Untitled"} · ${r.id} · ${r.cwd}`).replace(
305
+ /\n/gu,
306
+ " ",
307
+ ),
308
+ ),
309
+ ...controls,
310
+ ],
311
+ width,
312
+ );
313
+ }
314
+ handleInput(data: string): void {
315
+ this.safeInput.handleInput(data);
316
+ }
317
+ private handleSafeInput(data: string, paste: boolean): void {
318
+ const keys = getKeybindings();
319
+ if (!paste && keys.matches(data, "tui.select.cancel")) {
320
+ this.done(undefined);
321
+ return;
322
+ }
323
+ const filtered = this.filtered();
324
+ const index = Math.max(
325
+ 0,
326
+ filtered.findIndex((r) => rowKey(r) === this.selectedKey),
327
+ );
328
+ if (!paste && keys.matches(data, "tui.select.confirm")) {
329
+ this.render(this.terminalColumns());
330
+ if (this.ready) this.done(filtered.find((r) => rowKey(r) === this.selectedKey));
331
+ return;
332
+ }
333
+ const up = !paste && keys.matches(data, "tui.select.up");
334
+ const down = !paste && keys.matches(data, "tui.select.down");
335
+ const pageUp = !paste && keys.matches(data, "tui.select.pageUp");
336
+ const pageDown = !paste && keys.matches(data, "tui.select.pageDown");
337
+ if (up || down || pageUp || pageDown) {
338
+ const offset = up ? -1 : down ? 1 : pageUp ? -this.pageSize : this.pageSize;
339
+ const next = filtered[Math.max(0, Math.min(filtered.length - 1, index + offset))];
340
+ this.selectedKey = next && rowKey(next);
341
+ } else {
342
+ this.input.handleInput(data);
343
+ const next = this.filtered();
344
+ if (!next.some((r) => rowKey(r) === this.selectedKey)) this.selectedKey = next[0] && rowKey(next[0]);
345
+ }
346
+ this.renderAgain();
347
+ }
348
+ }
349
+
350
+ /** Compact composition of the public Editor, not ExtensionEditorComponent's
351
+ * dialog chrome (which has no row budget). No private editor/TUI state needed.
352
+ * The native viewport owns wrapping, scrolling, IME, undo and keybindings. */
353
+ class ReviewEditor implements Component, Focusable {
354
+ private readonly editor: Editor;
355
+ private ready = true;
356
+ private beforeInput: string | undefined;
357
+ private inputChanges: string[] | undefined;
358
+ private readonly safeInput = new SafeInput(({ kind, data }) => this.handleSafeInput(data, kind === "paste"));
359
+ get focused(): boolean {
360
+ return this.editor.focused;
361
+ }
362
+ set focused(value: boolean) {
363
+ this.editor.focused = value;
364
+ }
365
+ constructor(
366
+ private readonly surface: Surface,
367
+ private readonly title: string,
368
+ prefill: string,
369
+ private readonly done: (value: string | undefined) => void,
370
+ ) {
371
+ this.editor = new Editor(surface.tui, {
372
+ borderColor: (text) => text,
373
+ selectList: getSelectListTheme(),
374
+ });
375
+ this.editor.setText(plain(prefill));
376
+ this.editor.onChange = () => this.inputChanges?.push(this.editor.getExpandedText());
377
+ this.editor.onSubmit = () => {
378
+ // Native submission emits its final onChange("") after any escape-key
379
+ // edit (e.g. remapped backslash+Enter), then calls onSubmit with trimmed
380
+ // text. Keep the last pre-clear expanded document, not the stale input
381
+ // snapshot or the trimmed callback. All observation uses public APIs.
382
+ const changes = this.inputChanges;
383
+ const text = changes && changes.length > 1 ? changes[changes.length - 2] : this.beforeInput;
384
+ if (text === undefined) return;
385
+ if (this.ready) this.done(text);
386
+ };
387
+ }
388
+ invalidate(): void {
389
+ this.editor.invalidate();
390
+ }
391
+ render(width: number): string[] {
392
+ const keys = getKeybindings();
393
+ const controls = help(
394
+ `${keys.getKeys("tui.input.submit").join("/")} accept · ${keys.getKeys("tui.select.cancel").join("/")} cancel\n${keys.getKeys("tui.input.newLine").join("/")} newline · ↑↓/PgUp/PgDn move`,
395
+ width,
396
+ );
397
+ const rows = Math.max(1, this.surface.tui.terminal.rows);
398
+ // Pi 1.0.4's public Editor renders at most max(5, floor(rows*.3))
399
+ // content rows and two scroll borders. Reserve that whole viewport, one
400
+ // title row and all essential controls; never slice native render output.
401
+ const viewport = Math.max(5, Math.floor(rows * 0.3)) + 2;
402
+ this.ready = width >= 12 && viewport + 1 + controls.length <= rows;
403
+ if (!this.ready) return resizeNotice(width, rows);
404
+ return safeLines([plain(this.title).replace(/\n/gu, " "), ...this.editor.render(width), ...controls], width);
405
+ }
406
+ handleInput(data: string): void {
407
+ this.safeInput.handleInput(data);
408
+ }
409
+ private handleSafeInput(data: string, paste: boolean): void {
410
+ const keys = getKeybindings();
411
+ if (!paste && keys.matches(data, "tui.select.cancel")) {
412
+ this.done(undefined);
413
+ return;
414
+ }
415
+ if (paste) {
416
+ // SafeInput has decoded/normalized/sanitized this complete inert paste.
417
+ // Insert expanded text atomically through the public API: native paste
418
+ // markers would require submitValue(), which destroys all Undo history.
419
+ // A complete EMPTY native paste cancels pending Ctrl+] character-jump
420
+ // input without inserting text, moving the cursor, or resetting Undo.
421
+ this.editor.handleInput("\x1b[200~\x1b[201~");
422
+ this.editor.insertTextAtCursor(data.slice("\x1b[200~".length, -"\x1b[201~".length));
423
+ this.surface.tui.requestRender();
424
+ return;
425
+ }
426
+ // Keep the review inside its owned overlay; never launch an external editor.
427
+ if (this.surface.keys.matches(data, "app.editor.external")) return;
428
+ this.render(this.surface.tui.terminal.columns);
429
+ // Native newline-vs-submit precedence includes backslash+Enter/remaps.
430
+ this.editor.disableSubmit = !this.ready;
431
+ this.beforeInput = this.editor.getExpandedText();
432
+ this.inputChanges = [];
433
+ try {
434
+ this.editor.handleInput(data);
435
+ } finally {
436
+ this.beforeInput = undefined;
437
+ this.inputChanges = undefined;
438
+ }
439
+ this.surface.tui.requestRender();
440
+ }
441
+ }
442
+
443
+ export class HumanUI {
444
+ constructor(readonly owner: Owner<ExtensionContext>) {}
445
+ async confirm(title: string, message: string): Promise<boolean> {
446
+ const { ctx } = this.owner;
447
+ this.owner.check();
448
+ if (ctx.mode === "rpc")
449
+ return this.owner.wait(ctx.ui.confirm(label(title), plain(message), { signal: this.owner.signal }));
450
+ const choice = await modal<string>(
451
+ this.owner,
452
+ (s, done) =>
453
+ new Choice(
454
+ `${plain(title)}\n${plain(message)}`,
455
+ ["No", "Yes"],
456
+ done,
457
+ () => s.tui.requestRender(),
458
+ () => s.tui.terminal.rows,
459
+ () => s.tui.terminal.columns,
460
+ ),
461
+ );
462
+ this.owner.check();
463
+ return choice === "Yes";
464
+ }
465
+ async editor(title: string, prefill = ""): Promise<string | undefined> {
466
+ const { ctx } = this.owner;
467
+ this.owner.check();
468
+ if (ctx.mode === "rpc") return this.owner.wait(ctx.ui.editor(label(title), plain(prefill)));
469
+ const value = await modal<string>(this.owner, (s, done) => new ReviewEditor(s, label(title), plain(prefill), done));
470
+ this.owner.check();
471
+ return value;
472
+ }
473
+ async input(title: string): Promise<string | undefined> {
474
+ const { ctx } = this.owner;
475
+ if (ctx.mode === "rpc")
476
+ return this.owner.wait(ctx.ui.input(label(title), undefined, { signal: this.owner.signal }));
477
+ // Multi-line goal entry also supports IME and paste through Pi's native editor.
478
+ return this.editor(title);
479
+ }
480
+ async loading<T>(title: string, run: (signal: AbortSignal) => Promise<T>): Promise<T | undefined> {
481
+ const { ctx } = this.owner;
482
+ if (ctx.mode === "rpc") return this.owner.wait(run(this.owner.signal));
483
+ let result: T | undefined;
484
+ let failure: unknown;
485
+ await modal<boolean>(this.owner, (_s, done, isFocused) => {
486
+ const controller = new AbortController();
487
+ const abort = () => controller.abort();
488
+ this.owner.signal.addEventListener("abort", abort, { once: true });
489
+ this.owner
490
+ .wait(run(controller.signal))
491
+ .then(
492
+ (value) => {
493
+ if (!isFocused()) {
494
+ // A foreign interaction took focus while generation was pending.
495
+ // Retire rather than opening the next review over its draft/UI.
496
+ this.owner.retire();
497
+ return;
498
+ }
499
+ result = value;
500
+ done(true);
501
+ },
502
+ (error: unknown) => {
503
+ if (!isFocused()) {
504
+ this.owner.retire(); // A manual fallback must not steal foreign focus either.
505
+ return;
506
+ }
507
+ failure = error;
508
+ done(false);
509
+ },
510
+ )
511
+ .finally(() => this.owner.signal.removeEventListener("abort", abort));
512
+ return {
513
+ invalidate() {},
514
+ render: (width) => safeLines([plain(title), "Escape cancels generation"], width),
515
+ handleInput: (data) => {
516
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
517
+ controller.abort();
518
+ done(false);
519
+ }
520
+ },
521
+ };
522
+ });
523
+ this.owner.check();
524
+ if (failure) throw failure;
525
+ return result;
526
+ }
527
+ async pick(
528
+ load: (
529
+ publish: (rows: SessionRow[], loaded: number, total: number, complete: boolean) => void,
530
+ signal: AbortSignal,
531
+ ) => Promise<SessionRow[]>,
532
+ ): Promise<SessionRow | undefined> {
533
+ const { ctx } = this.owner;
534
+ if (ctx.mode === "rpc") {
535
+ const rows = await this.owner.wait(load(() => {}, this.owner.signal));
536
+ const filter = await this.input("Filter by session ID, name or workspace (empty shows all loaded rows)");
537
+ if (filter === undefined) return;
538
+ const query = plain(filter).toLocaleLowerCase();
539
+ const filtered = rows.filter((r) => `${r.id} ${r.name} ${r.cwd}`.toLocaleLowerCase().includes(query));
540
+ const options = filtered.map(
541
+ (r, i) => `${i + 1}. ${label(r.name ?? "Untitled", 90)} · ${label(r.id, 80)} · ${label(r.cwd, 120)}`,
542
+ );
543
+ if (!options.length) return;
544
+ const selected = await this.owner.wait(
545
+ ctx.ui.select("Select a session to preview", options, { signal: this.owner.signal }),
546
+ );
547
+ return filtered[options.indexOf(selected ?? "")];
548
+ }
549
+ return modal<SessionRow>(this.owner, (s, done) => {
550
+ const controller = new AbortController();
551
+ const abort = () => controller.abort();
552
+ this.owner.signal.addEventListener("abort", abort, { once: true });
553
+ const picker = new Picker(
554
+ (row) => {
555
+ controller.abort();
556
+ done(row);
557
+ },
558
+ () => s.tui.requestRender(),
559
+ () => s.tui.terminal.rows,
560
+ () => s.tui.terminal.columns,
561
+ );
562
+ this.owner
563
+ .wait(
564
+ load((rows, loaded, total, complete) => {
565
+ if (this.owner.current() && !controller.signal.aborted) picker.update(rows, loaded, total, complete);
566
+ }, controller.signal),
567
+ )
568
+ .catch(() => {
569
+ if (!controller.signal.aborted) done(undefined);
570
+ })
571
+ .finally(() => this.owner.signal.removeEventListener("abort", abort));
572
+ return picker;
573
+ });
574
+ }
575
+ }
576
+
577
+ export function requireHuman(ctx: ExtensionContext): void {
578
+ if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
579
+ throw new Error("/sessions and /handoff require TUI or dialog-capable RPC; unavailable in print/JSON mode.");
580
+ }
581
+ }