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

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