@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.83

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 (67) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/README.md +5 -1
  3. package/dist/node/index.d.ts +189 -54
  4. package/dist/node/index.d.ts.map +1 -1
  5. package/dist/node/index.js +21 -21
  6. package/dist/node/index.js.map +1 -1
  7. package/package.json +16 -21
  8. package/src/App.tsx +14 -3
  9. package/src/AppPresentation.tsx +1 -1
  10. package/src/PermissionPrompt.tsx +50 -17
  11. package/src/SessionPicker.tsx +27 -7
  12. package/src/SessionStatusBar.tsx +6 -2
  13. package/src/StatusBar.tsx +24 -0
  14. package/src/SupervisedSessionView.tsx +479 -157
  15. package/src/TuiInteractionChannel.ts +8 -53
  16. package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
  17. package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
  18. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
  19. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
  20. package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
  21. package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
  22. package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
  23. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
  24. package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
  25. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  26. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  27. package/src/__tests__/palette-consistency.test.ts +0 -1
  28. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  29. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
  30. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
  31. package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
  32. package/src/__tests__/render-channel-options.test.ts +6 -6
  33. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  34. package/src/__tests__/screen-reader-menus.test.tsx +1 -1
  35. package/src/__tests__/session-picker.test.tsx +93 -0
  36. package/src/__tests__/status-bar.test.tsx +8 -0
  37. package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
  38. package/src/__tests__/wire-tui-channel.test.ts +1445 -0
  39. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  40. package/src/app-view-model.ts +6 -2
  41. package/src/attached-session-connection.ts +14 -0
  42. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  43. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
  44. package/src/hooks/useAppController.ts +6 -2
  45. package/src/hooks/useAppInputBindings.ts +20 -2
  46. package/src/hooks/useAppInteractionState.ts +3 -0
  47. package/src/hooks/useAppOverlays.ts +20 -4
  48. package/src/hooks/useNumberedSelection.ts +19 -2
  49. package/src/hooks/useTuiChannel.ts +13 -1
  50. package/src/index.ts +17 -3
  51. package/src/render.tsx +218 -27
  52. package/src/short-session-id.ts +9 -0
  53. package/src/slash-command-input.ts +11 -0
  54. package/src/tui-app-channel-port.ts +29 -1
  55. package/src/tui-channel-options.ts +11 -2
  56. package/src/tui-session-event-projector.ts +0 -2
  57. package/src/tui-session-events.ts +6 -0
  58. package/src/tui-session-options.ts +9 -0
  59. package/src/tui-state-manager.ts +17 -3
  60. package/src/waiting-loop-stop-notice.ts +23 -0
  61. package/src/wire-history-sync.ts +159 -0
  62. package/src/wire-tui-channel.ts +962 -0
  63. package/src/wire-tui-client-commands.ts +142 -0
  64. package/src/wire-tui-projection.ts +136 -0
  65. package/dist/node/index.cjs +0 -32
  66. package/dist/node/index.d.cts +0 -1066
  67. package/dist/node/index.d.cts.map +0 -1
@@ -5,7 +5,10 @@ import stringWidth from 'string-width';
5
5
  import { useNumberedSelection } from './hooks/useNumberedSelection.js';
6
6
  import { formatNumberedSelectionPrompt, numberedRowPrefix } from './numbered-list.js';
7
7
  import { Text } from './SafeText.js';
8
- import { writeScreenReaderAnnouncement, type TScreenReaderChannel } from './screen-reader-announcement.js';
8
+ import {
9
+ writeScreenReaderAnnouncement,
10
+ type TScreenReaderChannel,
11
+ } from './screen-reader-announcement.js';
9
12
  import { ScreenReaderProvider, useScreenReader } from './screen-reader-context.js';
10
13
 
11
14
  export interface ISupervisedViewRow {
@@ -16,68 +19,128 @@ export interface ISupervisedViewRow {
16
19
  readonly nextLoopAt?: string;
17
20
  readonly name?: string;
18
21
  readonly cwd?: string;
19
- readonly pr?: { readonly url: string; readonly host: string; readonly number: number;
20
- readonly kind: 'pull' | 'merge-request' };
22
+ readonly pr?: {
23
+ readonly url: string;
24
+ readonly host: string;
25
+ readonly number: number;
26
+ readonly kind: 'pull' | 'merge-request';
27
+ };
21
28
  readonly problem?: 'invalid-registration';
29
+ /** The verified registration generation; a row without one never offers an action. Never rendered. */
30
+ readonly generation?: string;
22
31
  }
23
32
 
33
+ /** An attach the user confirmed in the view, bound to the process start the row showed. */
34
+ export interface ISupervisedAttachRequest {
35
+ readonly id: string;
36
+ readonly generation: string;
37
+ readonly mode: 'drive' | 'observe';
38
+ /** How the view was grouped, so it can reopen the same way after the attach. */
39
+ readonly groupByDirectory: boolean;
40
+ }
41
+
42
+ /** How the view ended: closed, or handed to the host to attach and then come back. */
43
+ export type TSupervisedViewExit =
44
+ | { readonly kind: 'closed' }
45
+ | ({ readonly kind: 'attach' } & ISupervisedAttachRequest);
46
+
47
+ const ROLE: Readonly<Record<ISupervisedAttachRequest['mode'], string>> = {
48
+ drive: 'drive (send prompts, answer its questions)',
49
+ observe: 'observe (read only)',
50
+ };
51
+
24
52
  export interface ISupervisedSessionViewProps {
25
53
  readonly loadRows: (signal: AbortSignal) => Promise<readonly ISupervisedViewRow[]>;
26
- readonly onStop?: (id: string) => Promise<void>;
54
+ /** Called once the user confirmed; the view then closes so the host can attach this terminal. */
55
+ readonly onAttach?: (request: ISupervisedAttachRequest) => void;
56
+ /** Receives the generation the row displayed, so a session restarted under the same id is refused. */
57
+ readonly onStop?: (id: string, generation: string) => Promise<void>;
27
58
  readonly onStart?: () => Promise<string>;
28
- readonly onOpenPr?: (id: string, url: string) => Promise<void>;
59
+ readonly onOpenPr?: (id: string, url: string, generation: string) => Promise<void>;
29
60
  readonly filteredByCwd?: boolean;
30
61
  readonly filteredByName?: boolean;
31
62
  readonly filteredByPr?: boolean;
32
63
  readonly stateFilter?: TGroup;
33
64
  readonly refreshMs?: number;
65
+ /** The row to select first, when it is still listed — the one an attach left from. */
66
+ readonly initialSelectedId?: string;
67
+ readonly initialGroupByDirectory?: boolean;
34
68
  }
35
69
 
36
70
  const GROUP_ORDER = ['needs-input', 'working', 'idle', 'unknown', 'unverified', 'dead'] as const;
37
- type TGroup = typeof GROUP_ORDER[number];
71
+ type TGroup = (typeof GROUP_ORDER)[number];
38
72
  type TDisplayLine =
39
73
  | { readonly kind: 'group'; readonly label: string }
40
74
  | { readonly kind: 'row'; readonly row: ISupervisedViewRow; readonly index: number };
41
75
 
76
+ function isControllable(
77
+ row: ISupervisedViewRow | undefined,
78
+ ): row is ISupervisedViewRow & { readonly generation: string } {
79
+ return (
80
+ row?.liveness === 'alive' && row.control === 'available' && row.generation !== undefined
81
+ );
82
+ }
83
+
42
84
  function groupOf(row: ISupervisedViewRow): TGroup {
43
85
  if (row.liveness === 'dead') return 'dead';
44
- if (row.liveness !== 'alive' || row.control !== 'available') return 'unverified';
86
+ if (!isControllable(row)) return 'unverified';
45
87
  return row.activity;
46
88
  }
47
89
 
48
90
  function sortedRows(rows: readonly ISupervisedViewRow[]): readonly ISupervisedViewRow[] {
49
- return [...rows].sort((a, b) =>
50
- GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) || a.id.localeCompare(b.id));
91
+ return [...rows].sort(
92
+ (a, b) =>
93
+ GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) || a.id.localeCompare(b.id),
94
+ );
51
95
  }
52
96
 
53
97
  function sortedDirectoryRows(rows: readonly ISupervisedViewRow[]): readonly ISupervisedViewRow[] {
54
- return [...rows].sort((a, b) =>
55
- (a.cwd === undefined ? 1 : 0) - (b.cwd === undefined ? 1 : 0) ||
56
- (a.cwd ?? '').localeCompare(b.cwd ?? '') ||
57
- GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) || a.id.localeCompare(b.id));
98
+ return [...rows].sort(
99
+ (a, b) =>
100
+ (a.cwd === undefined ? 1 : 0) - (b.cwd === undefined ? 1 : 0) ||
101
+ (a.cwd ?? '').localeCompare(b.cwd ?? '') ||
102
+ GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) ||
103
+ a.id.localeCompare(b.id),
104
+ );
58
105
  }
59
106
 
60
- function uniqueDirectorySuffix(cwd: string, directories: readonly string[], budget: number): string {
107
+ function uniqueDirectorySuffix(
108
+ cwd: string,
109
+ directories: readonly string[],
110
+ budget: number,
111
+ ): string {
61
112
  if (cwd === '/') return cwd;
62
113
  let longestSharedSuffix = 0;
63
114
  for (const other of directories) {
64
115
  if (other === cwd) continue;
65
116
  let shared = 0;
66
- while (shared < cwd.length && shared < other.length &&
67
- cwd[cwd.length - shared - 1] === other[other.length - shared - 1]) shared++;
117
+ while (
118
+ shared < cwd.length &&
119
+ shared < other.length &&
120
+ cwd[cwd.length - shared - 1] === other[other.length - shared - 1]
121
+ )
122
+ shared++;
68
123
  longestSharedSuffix = Math.max(longestSharedSuffix, shared);
69
124
  }
70
125
  const basenameLength = cwd.split(/[\\/]/u).filter(Boolean).at(-1)?.length ?? cwd.length;
71
126
  const uniqueLength = Math.min(cwd.length, Math.max(basenameLength, longestSharedSuffix + 3));
72
127
  const suffixStart = cwd.length - uniqueLength;
73
- const boundary = Math.max(cwd.lastIndexOf('/', suffixStart - 1), cwd.lastIndexOf('\\', suffixStart - 1));
128
+ const boundary = Math.max(
129
+ cwd.lastIndexOf('/', suffixStart - 1),
130
+ cwd.lastIndexOf('\\', suffixStart - 1),
131
+ );
74
132
  const componentSuffix = cwd.slice(boundary + 1);
75
- return stringWidth(displayPath(componentSuffix)) <= budget ? componentSuffix : cwd.slice(suffixStart);
133
+ return stringWidth(displayPath(componentSuffix)) <= budget
134
+ ? componentSuffix
135
+ : cwd.slice(suffixStart);
76
136
  }
77
137
 
78
138
  function pathTokens(path: string): readonly string[] {
79
- return Array.from(path, (character) => /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character)
80
- ? `\\u{${character.codePointAt(0)!.toString(16)}}` : character);
139
+ return Array.from(path, (character) =>
140
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character)
141
+ ? `\\u{${character.codePointAt(0)!.toString(16)}}`
142
+ : character,
143
+ );
81
144
  }
82
145
 
83
146
  function displayPath(path: string): string {
@@ -103,20 +166,34 @@ function compactPath(path: string, budget: number): string {
103
166
  }
104
167
 
105
168
  function sameRows(a: readonly ISupervisedViewRow[], b: readonly ISupervisedViewRow[]): boolean {
106
- return a.length === b.length && a.every((row, index) => {
107
- const other = b[index];
108
- return other !== undefined && row.id === other.id && row.liveness === other.liveness &&
109
- row.control === other.control && row.activity === other.activity && row.problem === other.problem &&
110
- row.nextLoopAt === other.nextLoopAt && row.name === other.name && row.cwd === other.cwd &&
111
- row.pr?.url === other.pr?.url;
112
- });
169
+ return (
170
+ a.length === b.length &&
171
+ a.every((row, index) => {
172
+ const other = b[index];
173
+ return (
174
+ other !== undefined &&
175
+ row.id === other.id &&
176
+ row.liveness === other.liveness &&
177
+ row.control === other.control &&
178
+ row.activity === other.activity &&
179
+ row.problem === other.problem &&
180
+ row.nextLoopAt === other.nextLoopAt &&
181
+ row.name === other.name &&
182
+ row.cwd === other.cwd &&
183
+ row.pr?.url === other.pr?.url &&
184
+ row.generation === other.generation
185
+ );
186
+ })
187
+ );
113
188
  }
114
189
 
115
190
  function loopWaitLabel(nextLoopAt: string, observedAtMs: number): string {
116
191
  const remaining = Date.parse(nextLoopAt) - observedAtMs;
117
192
  if (!Number.isFinite(remaining)) return '';
118
193
  if (remaining <= 0) return 'loop eligible now';
119
- return remaining < 60_000 ? 'loop eligible in <1m' : `loop eligible in ${Math.ceil(remaining / 60_000)}m`;
194
+ return remaining < 60_000
195
+ ? 'loop eligible in <1m'
196
+ : `loop eligible in ${Math.ceil(remaining / 60_000)}m`;
120
197
  }
121
198
 
122
199
  export default function SupervisedSessionView({
@@ -124,11 +201,14 @@ export default function SupervisedSessionView({
124
201
  onStop,
125
202
  onStart,
126
203
  onOpenPr,
204
+ onAttach,
127
205
  filteredByCwd = false,
128
206
  filteredByName = false,
129
207
  filteredByPr = false,
130
208
  stateFilter,
131
209
  refreshMs = 2_000,
210
+ initialSelectedId,
211
+ initialGroupByDirectory = false,
132
212
  }: ISupervisedSessionViewProps): React.ReactElement {
133
213
  const { exit } = useApp();
134
214
  const { stdout } = useStdout();
@@ -137,40 +217,65 @@ export default function SupervisedSessionView({
137
217
  const [rows, setRows] = useState<readonly ISupervisedViewRow[]>([]);
138
218
  const [observedAtMs, setObservedAtMs] = useState(Date.now);
139
219
  const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
140
- const [selectedId, setSelectedId] = useState<string | undefined>();
220
+ const [chosenId, setSelectedId] = useState<string | undefined>(initialSelectedId);
141
221
  const [showHelp, setShowHelp] = useState(false);
142
- const [groupByDirectory, setGroupByDirectory] = useState(false);
143
- const [confirmStopId, setConfirmStopId] = useState<string | undefined>();
144
- const [stopStatus, setStopStatus] = useState<'idle' | 'unavailable' | 'stopping' | 'stopped' | 'failed'>('idle');
222
+ const [groupByDirectory, setGroupByDirectory] = useState(initialGroupByDirectory);
223
+ // One confirmation at a time, for the one registration the row showed when the key was pressed.
224
+ const [confirmStop, setConfirmStop] = useState<
225
+ | {
226
+ readonly id: string;
227
+ readonly generation: string;
228
+ readonly action: 'stop' | 'attach';
229
+ readonly mode?: ISupervisedAttachRequest['mode'];
230
+ }
231
+ | undefined
232
+ >();
233
+ const confirmStopId = confirmStop?.id;
234
+ const [attachStatus, setAttachStatus] = useState<'idle' | 'unavailable'>('idle');
235
+ const [stopStatus, setStopStatus] = useState<
236
+ 'idle' | 'unavailable' | 'stopping' | 'stopped' | 'failed'
237
+ >('idle');
145
238
  const [lastStoppedId, setLastStoppedId] = useState<string | undefined>();
146
239
  const stoppingRef = useRef(false);
147
- const [startStatus, setStartStatus] = useState<'idle' | 'starting' | 'started' | 'failed'>('idle');
240
+ const [startStatus, setStartStatus] = useState<'idle' | 'starting' | 'started' | 'failed'>(
241
+ 'idle',
242
+ );
148
243
  const [lastStartedId, setLastStartedId] = useState<string | undefined>();
149
244
  const startingRef = useRef(false);
150
- const [prOpenStatus, setPrOpenStatus] = useState<'idle' | 'opening' | 'opened' | 'failed' | 'unavailable'>('idle');
245
+ const [prOpenStatus, setPrOpenStatus] = useState<
246
+ 'idle' | 'opening' | 'opened' | 'failed' | 'unavailable'
247
+ >('idle');
151
248
  const openingPrRef = useRef(false);
152
249
  const mountedRef = useRef(true);
153
250
  const ordered = useMemo(() => {
154
- const filtered = rows.filter((row) => stateFilter === undefined || groupOf(row) === stateFilter);
251
+ const filtered = rows.filter(
252
+ (row) => stateFilter === undefined || groupOf(row) === stateFilter,
253
+ );
155
254
  return groupByDirectory ? sortedDirectoryRows(filtered) : sortedRows(filtered);
156
255
  }, [rows, stateFilter, groupByDirectory]);
157
256
  const displayLines = useMemo((): readonly TDisplayLine[] => {
158
257
  const lines: TDisplayLine[] = [];
159
- const directories = [...new Set(ordered.map((row) => row.cwd).filter((cwd): cwd is string => cwd !== undefined))];
258
+ const directories = [
259
+ ...new Set(ordered.map((row) => row.cwd).filter((cwd): cwd is string => cwd !== undefined)),
260
+ ];
160
261
  let previousGroup: string | undefined;
161
262
  let directoryNumber = 0;
162
263
  ordered.forEach((row, index) => {
163
- const group = groupByDirectory ? row.cwd ?? 'unverified' : groupOf(row);
264
+ const group = groupByDirectory ? (row.cwd ?? 'unverified') : groupOf(row);
164
265
  if (group !== previousGroup) {
165
266
  const label = groupByDirectory
166
- ? row.cwd === undefined ? 'Directory: unverified'
267
+ ? row.cwd === undefined
268
+ ? 'Directory: unverified'
167
269
  : (() => {
168
- const prefix = `Dir ${++directoryNumber}: `;
169
- const budget = Math.max(4, columns - prefix.length);
170
- const suffix = compactPath(uniqueDirectorySuffix(row.cwd, directories, budget), budget);
171
- const full = `${prefix}${suffix} — ${displayPath(row.cwd)}`;
172
- return screenReader || stringWidth(full) <= columns ? full : `${prefix}${suffix}`;
173
- })()
270
+ const prefix = `Dir ${++directoryNumber}: `;
271
+ const budget = Math.max(4, columns - prefix.length);
272
+ const suffix = compactPath(
273
+ uniqueDirectorySuffix(row.cwd, directories, budget),
274
+ budget,
275
+ );
276
+ const full = `${prefix}${suffix} — ${displayPath(row.cwd)}`;
277
+ return screenReader || stringWidth(full) <= columns ? full : `${prefix}${suffix}`;
278
+ })()
174
279
  : `${group}:`;
175
280
  lines.push({ kind: 'group', label });
176
281
  }
@@ -189,14 +294,24 @@ export default function SupervisedSessionView({
189
294
  const next = sortedRows(await loadRows(controller.signal));
190
295
  if (!mounted || controller.signal.aborted) return;
191
296
  const nowMs = Date.now();
192
- setRows((previous) => sameRows(previous, next) ? previous : next);
193
- setObservedAtMs((previous) => next.some((row) => row.nextLoopAt !== undefined &&
194
- loopWaitLabel(row.nextLoopAt, previous) !== loopWaitLabel(row.nextLoopAt, nowMs)) ? nowMs : previous);
297
+ setRows((previous) => (sameRows(previous, next) ? previous : next));
298
+ setObservedAtMs((previous) =>
299
+ next.some(
300
+ (row) =>
301
+ row.nextLoopAt !== undefined &&
302
+ loopWaitLabel(row.nextLoopAt, previous) !== loopWaitLabel(row.nextLoopAt, nowMs),
303
+ )
304
+ ? nowMs
305
+ : previous,
306
+ );
195
307
  setStatus('ready');
196
308
  } catch {
197
309
  if (mounted && !controller.signal.aborted) setStatus('unavailable');
198
310
  } finally {
199
- if (mounted && !controller.signal.aborted) timer = setTimeout(() => { void poll(); }, refreshMs);
311
+ if (mounted && !controller.signal.aborted)
312
+ timer = setTimeout(() => {
313
+ void poll();
314
+ }, refreshMs);
200
315
  }
201
316
  };
202
317
  void poll();
@@ -209,39 +324,96 @@ export default function SupervisedSessionView({
209
324
 
210
325
  useEffect(() => {
211
326
  mountedRef.current = true;
212
- return () => { mountedRef.current = false; };
327
+ return () => {
328
+ mountedRef.current = false;
329
+ };
213
330
  }, []);
214
331
 
332
+ // Derived during render, so no frame ever shows loaded rows without a selection. The effect then
333
+ // records the fallback with a functional update, so it never overwrites a choice made meanwhile,
334
+ // and the selected row stays selected when regrouping reorders the list.
335
+ const selectedId =
336
+ chosenId !== undefined && ordered.some((row) => row.id === chosenId)
337
+ ? chosenId
338
+ : ordered[0]?.id;
215
339
  useEffect(() => {
216
- setSelectedId((current) => current !== undefined && ordered.some((row) => row.id === current)
217
- ? current : ordered[0]?.id);
340
+ // Before the first rows arrive there is nothing to fall back to; the choice waits for them.
341
+ setSelectedId((current) =>
342
+ ordered.length === 0 ||
343
+ (current !== undefined && ordered.some((row) => row.id === current))
344
+ ? current
345
+ : ordered[0]?.id,
346
+ );
218
347
  }, [ordered]);
219
348
 
220
349
  useInput((input, key) => {
221
350
  if (stoppingRef.current) return;
222
- if (openingPrRef.current && input !== 'q' && !(!screenReader && key.escape) && !(key.ctrl && input === 'c')) return;
223
- if (startingRef.current && input !== 'q' && !(!screenReader && key.escape) && !(key.ctrl && input === 'c')) return;
351
+ if (
352
+ openingPrRef.current &&
353
+ input !== 'q' &&
354
+ !(!screenReader && key.escape) &&
355
+ !(key.ctrl && input === 'c')
356
+ )
357
+ return;
358
+ if (
359
+ startingRef.current &&
360
+ input !== 'q' &&
361
+ !(!screenReader && key.escape) &&
362
+ !(key.ctrl && input === 'c')
363
+ )
364
+ return;
224
365
  if (confirmStopId !== undefined) {
225
366
  if (input === 'n' || key.escape) {
226
- setConfirmStopId(undefined);
367
+ setConfirmStop(undefined);
368
+ return;
369
+ }
370
+ if (input === 'y' && confirmStop?.action === 'attach') {
371
+ const target = confirmStop;
372
+ const row = ordered.find((candidate) => candidate.id === target.id);
373
+ setConfirmStop(undefined);
374
+ if (
375
+ status !== 'ready' ||
376
+ !isControllable(row) ||
377
+ row.generation !== target.generation ||
378
+ target.mode === undefined ||
379
+ onAttach === undefined
380
+ ) {
381
+ setAttachStatus('unavailable');
382
+ return;
383
+ }
384
+ onAttach({ id: target.id, generation: target.generation, mode: target.mode, groupByDirectory });
385
+ exit();
227
386
  return;
228
387
  }
229
388
  if (input === 'y') {
230
- const row = ordered.find((candidate) => candidate.id === confirmStopId);
231
- setConfirmStopId(undefined);
232
- if (status !== 'ready' || row?.liveness !== 'alive' || row.control !== 'available' || onStop === undefined) {
389
+ const target = confirmStop;
390
+ const row = ordered.find((candidate) => candidate.id === target?.id);
391
+ setConfirmStop(undefined);
392
+ // The confirmation names one registration; a restart under the same id is a different session.
393
+ if (
394
+ status !== 'ready' ||
395
+ target === undefined ||
396
+ !isControllable(row) ||
397
+ row.generation !== target.generation ||
398
+ onStop === undefined
399
+ ) {
233
400
  setStopStatus('unavailable');
234
401
  return;
235
402
  }
236
403
  stoppingRef.current = true;
237
404
  setStopStatus('stopping');
238
- void onStop(confirmStopId).then(() => {
239
- if (!mountedRef.current) return;
240
- setLastStoppedId(confirmStopId);
241
- setStopStatus('stopped');
242
- }).catch(() => {
243
- if (mountedRef.current) setStopStatus('failed');
244
- }).finally(() => { stoppingRef.current = false; });
405
+ void onStop(target.id, target.generation)
406
+ .then(() => {
407
+ if (!mountedRef.current) return;
408
+ setLastStoppedId(target.id);
409
+ setStopStatus('stopped');
410
+ })
411
+ .catch(() => {
412
+ if (mountedRef.current) setStopStatus('failed');
413
+ })
414
+ .finally(() => {
415
+ stoppingRef.current = false;
416
+ });
245
417
  }
246
418
  return;
247
419
  }
@@ -261,43 +433,74 @@ export default function SupervisedSessionView({
261
433
  if (input === 'n' && onStart !== undefined) {
262
434
  startingRef.current = true;
263
435
  setStartStatus('starting');
264
- void Promise.resolve().then(onStart).then((id) => {
265
- if (!mountedRef.current) return;
266
- setLastStartedId(id);
267
- setStartStatus('started');
268
- }).catch(() => {
269
- if (mountedRef.current) setStartStatus('failed');
270
- }).finally(() => { startingRef.current = false; });
436
+ void Promise.resolve()
437
+ .then(onStart)
438
+ .then((id) => {
439
+ if (!mountedRef.current) return;
440
+ setLastStartedId(id);
441
+ setStartStatus('started');
442
+ })
443
+ .catch(() => {
444
+ if (mountedRef.current) setStartStatus('failed');
445
+ })
446
+ .finally(() => {
447
+ startingRef.current = false;
448
+ });
271
449
  return;
272
450
  }
273
- if (input === 'p' && onOpenPr !== undefined) {
451
+ if ((input === 'a' || input === 'p') && onAttach !== undefined) {
274
452
  const row = ordered.find((candidate) => candidate.id === selectedId);
275
- if (status !== 'ready' || row?.liveness !== 'alive' || row.control !== 'available' || !row.pr) {
453
+ if (status !== 'ready' || !isControllable(row)) {
454
+ setAttachStatus('unavailable');
455
+ } else {
456
+ setAttachStatus('idle');
457
+ setStopStatus('idle');
458
+ setConfirmStop({
459
+ id: row.id,
460
+ generation: row.generation,
461
+ action: 'attach',
462
+ mode: input === 'a' ? 'drive' : 'observe',
463
+ });
464
+ }
465
+ return;
466
+ }
467
+ if (input === 'o' && onOpenPr !== undefined) {
468
+ const row = ordered.find((candidate) => candidate.id === selectedId);
469
+ if (status !== 'ready' || !isControllable(row) || !row.pr) {
276
470
  setPrOpenStatus('unavailable');
277
471
  return;
278
472
  }
279
473
  openingPrRef.current = true;
280
474
  setPrOpenStatus('opening');
281
- void onOpenPr(row.id, row.pr.url).then(() => {
282
- if (mountedRef.current) setPrOpenStatus('opened');
283
- }).catch(() => {
284
- if (mountedRef.current) setPrOpenStatus('failed');
285
- }).finally(() => { openingPrRef.current = false; });
475
+ void onOpenPr(row.id, row.pr.url, row.generation)
476
+ .then(() => {
477
+ if (mountedRef.current) setPrOpenStatus('opened');
478
+ })
479
+ .catch(() => {
480
+ if (mountedRef.current) setPrOpenStatus('failed');
481
+ })
482
+ .finally(() => {
483
+ openingPrRef.current = false;
484
+ });
286
485
  return;
287
486
  }
288
487
  if (input === 's' && onStop !== undefined) {
289
488
  const row = ordered.find((candidate) => candidate.id === selectedId);
290
- if (status !== 'ready' || row?.liveness !== 'alive' || row.control !== 'available') {
489
+ if (status !== 'ready' || !isControllable(row)) {
291
490
  setStopStatus('unavailable');
292
491
  } else {
293
492
  setStopStatus('idle');
294
- setConfirmStopId(row.id);
493
+ setAttachStatus('idle');
494
+ setConfirmStop({ id: row.id, generation: row.generation, action: 'stop' });
295
495
  }
296
496
  return;
297
497
  }
298
498
  if (screenReader || ordered.length === 0) return;
299
499
  if (key.upArrow || key.downArrow) {
300
- const index = Math.max(0, ordered.findIndex((row) => row.id === selectedId));
500
+ const index = Math.max(
501
+ 0,
502
+ ordered.findIndex((row) => row.id === selectedId),
503
+ );
301
504
  const next = Math.max(0, Math.min(ordered.length - 1, index + (key.downArrow ? 1 : -1)));
302
505
  setSelectedId(ordered[next]?.id);
303
506
  }
@@ -312,17 +515,22 @@ export default function SupervisedSessionView({
312
515
  setSelectedId(ordered[index]?.id);
313
516
  setStopStatus('idle');
314
517
  },
315
- onCancel: () => { if (!stoppingRef.current) exit(); },
518
+ onCancel: () => {
519
+ if (!stoppingRef.current) exit();
520
+ },
316
521
  });
317
522
 
318
523
  const selectedRow = ordered.find((row) => row.id === selectedId);
319
- const selectedName = status === 'ready' && selectedRow?.liveness === 'alive' &&
320
- selectedRow.control === 'available' ? selectedRow.name : undefined;
321
- const selectedLoopStatus = status === 'ready' && selectedRow?.liveness === 'alive' &&
322
- selectedRow.control === 'available' && selectedRow.activity === 'idle' && selectedRow.nextLoopAt
323
- ? loopWaitLabel(selectedRow.nextLoopAt, observedAtMs) : '';
324
- const selectedPr = status === 'ready' && selectedRow?.liveness === 'alive' &&
325
- selectedRow.control === 'available' ? selectedRow.pr : undefined;
524
+ const selectedName =
525
+ status === 'ready' && isControllable(selectedRow) ? selectedRow.name : undefined;
526
+ const selectedLoopStatus =
527
+ status === 'ready' &&
528
+ isControllable(selectedRow) &&
529
+ selectedRow.activity === 'idle' &&
530
+ selectedRow.nextLoopAt
531
+ ? loopWaitLabel(selectedRow.nextLoopAt, observedAtMs)
532
+ : '';
533
+ const selectedPr = status === 'ready' && isControllable(selectedRow) ? selectedRow.pr : undefined;
326
534
 
327
535
  const height = Math.max(8, stdout.rows ?? 24);
328
536
  const helpLines = [
@@ -331,9 +539,10 @@ export default function SupervisedSessionView({
331
539
  's Request stop',
332
540
  'g Group state/dir',
333
541
  ...(onStart === undefined ? [] : ['n New session']),
334
- ...(onOpenPr === undefined ? [] : ['p Open linked PR']),
335
- 'y Confirm stop',
336
- 'n/Esc Cancel stop',
542
+ ...(onAttach === undefined ? [] : ['a Attach (drive)', 'p Peek (read only)']),
543
+ ...(onOpenPr === undefined ? [] : ['o Open linked PR']),
544
+ 'y Confirm',
545
+ 'n/Esc Cancel',
337
546
  'q/Esc/Ctrl+C Close',
338
547
  '? Toggle help',
339
548
  'Activity ≠ liveness',
@@ -342,84 +551,179 @@ export default function SupervisedSessionView({
342
551
  ];
343
552
  // Reserve all fixed chrome plus both possible overflow indicators before choosing row lines.
344
553
  const helpVisible = showHelp && confirmStopId === undefined && stopStatus !== 'stopping';
345
- const fixedLines = 2 + (stateFilter === undefined ? 0 : 1)
346
- + (status === 'loading' || status === 'unavailable' || (status === 'ready' && ordered.length === 0) ? 1 : 0)
347
- + (selectedId === undefined ? 0 : 1)
348
- + (selectedName === undefined ? 0 : 1)
349
- + (selectedPr === undefined ? 0 : 2)
350
- + (selectedLoopStatus ? 1 : 0)
351
- + (prOpenStatus === 'idle' ? 0 : 1)
352
- + (startStatus === 'idle' ? 0 : 1)
353
- + (confirmStopId !== undefined ? (screenReader ? 1 : 2) : stopStatus !== 'idle' ? 1 : 0)
354
- + 1 + (helpVisible ? helpLines.length : 0) + 2;
554
+ const fixedLines =
555
+ 2 +
556
+ (stateFilter === undefined ? 0 : 1) +
557
+ (status === 'loading' ||
558
+ status === 'unavailable' ||
559
+ (status === 'ready' && ordered.length === 0)
560
+ ? 1
561
+ : 0) +
562
+ (selectedId === undefined ? 0 : 1) +
563
+ (selectedName === undefined ? 0 : 1) +
564
+ (selectedPr === undefined ? 0 : 2) +
565
+ (selectedLoopStatus ? 1 : 0) +
566
+ (prOpenStatus === 'idle' ? 0 : 1) +
567
+ (startStatus === 'idle' ? 0 : 1) +
568
+ (confirmStopId !== undefined ? (screenReader ? 1 : 2) : stopStatus !== 'idle' ? 1 : 0) +
569
+ (confirmStopId === undefined && attachStatus !== 'idle' ? 1 : 0) +
570
+ 1 +
571
+ (helpVisible ? helpLines.length : 0) +
572
+ 2;
355
573
  const viewport = Math.max(1, height - fixedLines);
356
- const selectedLine = Math.max(0, displayLines.findIndex((line) => line.kind === 'row' && line.row.id === selectedId));
357
- const start = Math.min(Math.max(0, selectedLine - Math.floor(viewport / 2)), Math.max(0, displayLines.length - viewport));
574
+ const selectedLine = Math.max(
575
+ 0,
576
+ displayLines.findIndex((line) => line.kind === 'row' && line.row.id === selectedId),
577
+ );
578
+ const start = Math.min(
579
+ Math.max(0, selectedLine - Math.floor(viewport / 2)),
580
+ Math.max(0, displayLines.length - viewport),
581
+ );
358
582
  const visible = screenReader ? displayLines : displayLines.slice(start, start + viewport);
359
583
  const chromeWrap = screenReader ? {} : { wrap: 'truncate-end' as const };
360
- const footer = stopStatus === 'stopping' ? 'Stop in progress; wait for result.'
361
- : confirmStopId !== undefined ? 'Confirm stop or cancel before closing.'
362
- : screenReader ? `Type a number and Enter to select; s Stop;${onStart ? ' n New;' : ''}${onOpenPr ? ' p Open PR;' : ''} g Group; Escape to close; ? Help.`
363
- : `↑↓ Navigate s Stop${onStart ? ' n New' : ''}${onOpenPr ? ' p Open PR' : ''} g Group ? Help q/Esc Close`;
584
+ // Attach and peek are offered only for a row this terminal could actually attach to.
585
+ const attachable = onAttach !== undefined && status === 'ready' && isControllable(selectedRow);
586
+ const footer =
587
+ stopStatus === 'stopping'
588
+ ? 'Stop in progress; wait for result.'
589
+ : confirmStopId !== undefined
590
+ ? `Confirm ${confirmStop?.action === 'attach' ? 'attach' : 'stop'} or cancel before closing.`
591
+ : screenReader
592
+ ? `Type a number and Enter to select; s Stop;${attachable ? ' a Attach; p Peek;' : ''}${onStart ? ' n New;' : ''}${onOpenPr ? ' o Open PR;' : ''} g Group; Escape to close; ? Help.`
593
+ : `↑↓ Navigate s Stop${attachable ? ' a Attach p Peek' : ''}${onStart ? ' n New' : ''}${onOpenPr ? ' o Open PR' : ''} g Group ? Help q/Esc Close`;
594
+ const confirmQuestion = (id: string): string =>
595
+ confirmStop?.action === 'attach' && confirmStop.mode !== undefined
596
+ ? `${confirmStop.mode === 'drive' ? 'Attach to' : 'Peek at'} ${id} to ${ROLE[confirmStop.mode]}?`
597
+ : `Stop ${id}?`;
364
598
  return (
365
599
  <Box flexDirection="column" {...(screenReader ? {} : { height })}>
366
600
  <Text {...chromeWrap}>
367
- {filteredByCwd ? 'Background sessions in selected directory' : 'Background sessions across projects'}
601
+ {filteredByCwd
602
+ ? 'Background sessions in selected directory'
603
+ : 'Background sessions across projects'}
368
604
  {filteredByName ? ' · name filter active' : ''}
369
605
  {filteredByPr ? ' · PR filter active' : ''}
370
606
  </Text>
371
- <Text {...chromeWrap}>{ordered.length} supervised session(s). Foreground peers and saved records are separate.</Text>
607
+ <Text {...chromeWrap}>
608
+ {ordered.length} supervised session(s). Foreground peers and saved records are separate.
609
+ </Text>
372
610
  {stateFilter !== undefined && <Text {...chromeWrap}>State: {stateFilter}</Text>}
373
611
  {status === 'loading' && <Text {...chromeWrap}>Loading supervised sessions...</Text>}
374
- {status === 'unavailable' && <Text {...chromeWrap}>Supervised session discovery unavailable; last verified rows remain below.</Text>}
375
- {status === 'ready' && ordered.length === 0 && <Text {...chromeWrap}>No supervised sessions.</Text>}
612
+ {status === 'unavailable' && (
613
+ <Text {...chromeWrap}>
614
+ Supervised session discovery unavailable; last verified rows remain below.
615
+ </Text>
616
+ )}
617
+ {status === 'ready' && ordered.length === 0 && (
618
+ <Text {...chromeWrap}>No supervised sessions.</Text>
619
+ )}
376
620
  {start > 0 && !screenReader && <Text>{start} more above</Text>}
377
- {visible.map((line) => line.kind === 'group'
378
- ? <Text key={`group-${line.label}`} {...chromeWrap}>{line.label}</Text>
379
- : <Text key={`row-${line.row.id}`} {...(screenReader ? {} : { wrap: 'truncate-end' as const })}>
380
- {screenReader ? numberedRowPrefix(line.index) : line.row.id === selectedId ? '> ' : ' '}
381
- {line.row.name && line.row.liveness === 'alive' && line.row.control === 'available'
382
- ? screenReader
383
- ? `${line.row.name} (${line.row.id})`
384
- : `${Array.from(line.row.name).slice(0, 24).join('')}${Array.from(line.row.name).length > 24 ? '…' : ''} [${line.row.id.slice(-8)}]`
385
- : line.row.id} activity {line.row.activity} liveness {line.row.liveness} control {line.row.control}
386
- {status === 'ready' && line.row.liveness === 'alive' && line.row.control === 'available' &&
387
- line.row.activity === 'idle' && line.row.nextLoopAt
388
- ? ` ${loopWaitLabel(line.row.nextLoopAt, observedAtMs)}` : ''}
389
- {line.row.problem ? ` ${line.row.problem}` : ''}
390
- {status === 'ready' && line.row.liveness === 'alive' && line.row.control === 'available' && line.row.pr
391
- ? ` ${line.row.pr.host} ${line.row.pr.kind === 'pull' ? '#' : '!'}${line.row.pr.number}` : ''}
392
- </Text>)}
393
- {!screenReader && start + visible.length < displayLines.length &&
394
- <Text>{displayLines.length - start - visible.length} more below</Text>}
621
+ {visible.map((line) =>
622
+ line.kind === 'group' ? (
623
+ <Text key={`group-${line.label}`} {...chromeWrap}>
624
+ {line.label}
625
+ </Text>
626
+ ) : (
627
+ <Text
628
+ key={`row-${line.row.id}`}
629
+ {...(screenReader ? {} : { wrap: 'truncate-end' as const })}
630
+ >
631
+ {screenReader
632
+ ? numberedRowPrefix(line.index)
633
+ : line.row.id === selectedId
634
+ ? '> '
635
+ : ' '}
636
+ {line.row.name && isControllable(line.row)
637
+ ? screenReader
638
+ ? `${line.row.name} (${line.row.id})`
639
+ : `${Array.from(line.row.name).slice(0, 24).join('')}${Array.from(line.row.name).length > 24 ? '…' : ''} [${line.row.id.slice(-8)}]`
640
+ : line.row.id}{' '}
641
+ activity {line.row.activity} liveness {line.row.liveness} control {line.row.control}
642
+ {status === 'ready' &&
643
+ isControllable(line.row) &&
644
+ line.row.activity === 'idle' &&
645
+ line.row.nextLoopAt
646
+ ? ` ${loopWaitLabel(line.row.nextLoopAt, observedAtMs)}`
647
+ : ''}
648
+ {line.row.problem ? ` ${line.row.problem}` : ''}
649
+ {status === 'ready' && isControllable(line.row) && line.row.pr
650
+ ? ` ${line.row.pr.host} ${line.row.pr.kind === 'pull' ? '#' : '!'}${line.row.pr.number}`
651
+ : ''}
652
+ </Text>
653
+ ),
654
+ )}
655
+ {!screenReader && start + visible.length < displayLines.length && (
656
+ <Text>{displayLines.length - start - visible.length} more below</Text>
657
+ )}
395
658
  {selectedId !== undefined && <Text {...chromeWrap}>Selected {selectedId}</Text>}
396
659
  {selectedName !== undefined && <Text {...chromeWrap}>Name: {selectedName}</Text>}
397
- {selectedPr !== undefined && <Text {...chromeWrap}>PR: {selectedPr.host} {selectedPr.kind === 'pull' ? '#' : '!'}{selectedPr.number}</Text>}
660
+ {selectedPr !== undefined && (
661
+ <Text {...chromeWrap}>
662
+ PR: {selectedPr.host} {selectedPr.kind === 'pull' ? '#' : '!'}
663
+ {selectedPr.number}
664
+ </Text>
665
+ )}
398
666
  {selectedPr !== undefined && <Text {...chromeWrap}>URL: {selectedPr.url}</Text>}
399
667
  {selectedLoopStatus && <Text {...chromeWrap}>{selectedLoopStatus}</Text>}
400
668
  {prOpenStatus === 'opening' && <Text {...chromeWrap}>Opening linked PR...</Text>}
401
669
  {prOpenStatus === 'opened' && <Text {...chromeWrap}>Opened linked PR.</Text>}
402
- {prOpenStatus === 'failed' && <Text {...chromeWrap}>PR link changed or could not be opened.</Text>}
403
- {prOpenStatus === 'unavailable' && <Text {...chromeWrap}>No verified PR link is available.</Text>}
670
+ {prOpenStatus === 'failed' && (
671
+ <Text {...chromeWrap}>PR link changed or could not be opened.</Text>
672
+ )}
673
+ {prOpenStatus === 'unavailable' && (
674
+ <Text {...chromeWrap}>No verified PR link is available.</Text>
675
+ )}
404
676
  {startStatus === 'starting' && <Text {...chromeWrap}>Starting a background session...</Text>}
405
- {startStatus === 'started' && <Text {...chromeWrap}>Started {lastStartedId}{filteredByName || stateFilter !== undefined ? ' (may be hidden by filter)' : ''}</Text>}
406
- {startStatus === 'failed' && <Text {...chromeWrap}>Start failed; check workspace trust or run session start --background for details.</Text>}
407
- {confirmStopId !== undefined && (screenReader
408
- ? <Text>Stop {confirmStopId}? y Yes / n No</Text>
409
- : <>
410
- <Text {...chromeWrap}>Stop …{confirmStopId.slice(-8)}?</Text>
411
- <Text {...chromeWrap}>y Yes / n No</Text>
412
- </>)}
413
- {confirmStopId === undefined && stopStatus === 'unavailable' &&
414
- <Text {...chromeWrap}>This session cannot be stopped from the view.</Text>}
415
- {stopStatus === 'stopping' && <Text {...chromeWrap}>Stopping selected session; wait for confirmation.</Text>}
677
+ {startStatus === 'started' && (
678
+ <Text {...chromeWrap}>
679
+ Started {lastStartedId}
680
+ {filteredByName || stateFilter !== undefined ? ' (may be hidden by filter)' : ''}
681
+ </Text>
682
+ )}
683
+ {startStatus === 'failed' && (
684
+ <Text {...chromeWrap}>
685
+ Start failed; check workspace trust or run session start --background for details.
686
+ </Text>
687
+ )}
688
+ {confirmStopId !== undefined &&
689
+ (screenReader ? (
690
+ <Text>{confirmQuestion(confirmStopId)} y Yes / n No</Text>
691
+ ) : (
692
+ <>
693
+ <Text {...chromeWrap}>{confirmQuestion(`…${confirmStopId.slice(-8)}`)}</Text>
694
+ <Text {...chromeWrap}>y Yes / n No</Text>
695
+ </>
696
+ ))}
697
+ {confirmStopId === undefined && stopStatus === 'unavailable' && (
698
+ <Text {...chromeWrap}>This session cannot be stopped from the view.</Text>
699
+ )}
700
+ {confirmStopId === undefined && attachStatus === 'unavailable' && (
701
+ <Text {...chromeWrap}>This session cannot be attached to from the view.</Text>
702
+ )}
703
+ {stopStatus === 'stopping' && (
704
+ <Text {...chromeWrap}>Stopping selected session; wait for confirmation.</Text>
705
+ )}
416
706
  {stopStatus === 'stopped' && <Text {...chromeWrap}>Stopped {lastStoppedId}</Text>}
417
- {stopStatus === 'failed' && <Text {...chromeWrap}>Stop failed; session remains listed until verified otherwise.</Text>}
418
- {screenReader && ordered.length > 0 && confirmStopId === undefined && stopStatus !== 'stopping' &&
419
- <Text>{formatNumberedSelectionPrompt(ordered.length, true)}{numbered.buffer ? ` ${numbered.buffer}` : ''}</Text>}
707
+ {stopStatus === 'failed' && (
708
+ <Text {...chromeWrap}>Stop failed; session remains listed until verified otherwise.</Text>
709
+ )}
710
+ {screenReader &&
711
+ ordered.length > 0 &&
712
+ confirmStopId === undefined &&
713
+ stopStatus !== 'stopping' && (
714
+ <Text>
715
+ {formatNumberedSelectionPrompt(ordered.length, true)}
716
+ {numbered.buffer ? ` ${numbered.buffer}` : ''}
717
+ </Text>
718
+ )}
420
719
  {screenReader && numbered.invalid && <Text>Selection out of range.</Text>}
421
720
  <Text {...chromeWrap}>{footer}</Text>
422
- {helpVisible && helpLines.map((line) => <Text key={line} {...chromeWrap}>{line}</Text>)}
721
+ {helpVisible &&
722
+ helpLines.map((line) => (
723
+ <Text key={line} {...chromeWrap}>
724
+ {line}
725
+ </Text>
726
+ ))}
423
727
  </Box>
424
728
  );
425
729
  }
@@ -429,21 +733,38 @@ export async function renderSupervisedSessionView(
429
733
  readonly screenReader: boolean;
430
734
  readonly screenReaderChannel?: TScreenReaderChannel;
431
735
  readonly screenReaderHint?: boolean;
736
+ /** Print the screen-reader line; false when this process already printed it. */
737
+ readonly announce?: boolean;
432
738
  },
433
- ): Promise<void> {
434
- writeScreenReaderAnnouncement({
435
- enabled: options.screenReader,
436
- channel: options.screenReaderChannel,
437
- hint: options.screenReaderHint,
438
- });
739
+ ): Promise<TSupervisedViewExit> {
740
+ if (options.announce !== false) {
741
+ writeScreenReaderAnnouncement({
742
+ enabled: options.screenReader,
743
+ channel: options.screenReaderChannel,
744
+ hint: options.screenReaderHint,
745
+ });
746
+ }
747
+ let attach: ISupervisedAttachRequest | undefined;
439
748
  const instance = render(
440
749
  <ScreenReaderProvider enabled={options.screenReader}>
441
- <SupervisedSessionView loadRows={options.loadRows} onStop={options.onStop}
750
+ <SupervisedSessionView
751
+ loadRows={options.loadRows}
752
+ onStop={options.onStop}
442
753
  onStart={options.onStart}
443
754
  onOpenPr={options.onOpenPr}
444
- filteredByCwd={options.filteredByCwd} filteredByName={options.filteredByName}
755
+ onAttach={(request) => {
756
+ attach = request;
757
+ }}
758
+ filteredByCwd={options.filteredByCwd}
759
+ filteredByName={options.filteredByName}
445
760
  filteredByPr={options.filteredByPr}
446
- stateFilter={options.stateFilter} refreshMs={options.refreshMs} />
761
+ stateFilter={options.stateFilter}
762
+ refreshMs={options.refreshMs}
763
+ {...(options.initialSelectedId !== undefined ? { initialSelectedId: options.initialSelectedId } : {})}
764
+ {...(options.initialGroupByDirectory !== undefined
765
+ ? { initialGroupByDirectory: options.initialGroupByDirectory }
766
+ : {})}
767
+ />
447
768
  </ScreenReaderProvider>,
448
769
  { isScreenReaderEnabled: options.screenReader, exitOnCtrlC: false },
449
770
  );
@@ -452,4 +773,5 @@ export async function renderSupervisedSessionView(
452
773
  } finally {
453
774
  instance.unmount();
454
775
  }
776
+ return attach === undefined ? { kind: 'closed' } : { kind: 'attach', ...attach };
455
777
  }