@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
@@ -7,15 +7,23 @@ import SupervisedSessionView, { type ISupervisedViewRow } from '../SupervisedSes
7
7
 
8
8
  const FIRST: ISupervisedViewRow = {
9
9
  id: '8bf9bc27-d773-4e88-b88f-f7a43e9eb1f4',
10
- liveness: 'alive', control: 'available', activity: 'working',
10
+ liveness: 'alive',
11
+ control: 'available',
12
+ activity: 'working',
13
+ generation: 'firstGenerationValue01',
11
14
  };
12
15
  const SECOND: ISupervisedViewRow = {
13
16
  id: 'fe2c7f72-ecb3-4a05-9bb1-2563ec80e615',
14
- liveness: 'dead', control: 'unavailable', activity: 'unknown',
17
+ liveness: 'dead',
18
+ control: 'unavailable',
19
+ activity: 'unknown',
15
20
  };
16
21
  const THIRD: ISupervisedViewRow = {
17
22
  id: '4fa26e15-b17d-4908-afed-a156f8a8a17c',
18
- liveness: 'alive', control: 'available', activity: 'idle',
23
+ liveness: 'alive',
24
+ control: 'available',
25
+ activity: 'idle',
26
+ generation: 'thirdGenerationValue01',
19
27
  };
20
28
 
21
29
  describe('supervised session view', () => {
@@ -28,37 +36,56 @@ describe('supervised session view', () => {
28
36
  await vi.waitFor(() => expect(view.lastFrame()).toContain('github.com #123'));
29
37
  expect(view.lastFrame()).toContain(url);
30
38
  expect(onOpenPr).not.toHaveBeenCalled();
31
- view.stdin.write('p');
32
- await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url));
33
- } finally { view.unmount(); }
39
+ view.stdin.write('o');
40
+ await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url, FIRST.generation));
41
+ } finally {
42
+ view.unmount();
43
+ }
34
44
  });
35
45
 
36
46
  it('hides stale PR links and refuses open when discovery fails', async () => {
37
47
  const url = 'https://github.com/team/repo/pull/123';
38
48
  let fail = false;
39
49
  const onOpenPr = vi.fn(async () => undefined);
40
- const view = render(<SupervisedSessionView refreshMs={20} onOpenPr={onOpenPr}
41
- loadRows={async () => {
42
- if (fail) throw new Error('offline');
43
- return [{ ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } }];
44
- }} />);
50
+ const view = render(
51
+ <SupervisedSessionView
52
+ refreshMs={20}
53
+ onOpenPr={onOpenPr}
54
+ loadRows={async () => {
55
+ if (fail) throw new Error('offline');
56
+ return [
57
+ { ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } },
58
+ ];
59
+ }}
60
+ />,
61
+ );
45
62
  try {
46
63
  await vi.waitFor(() => expect(view.lastFrame()).toContain(url));
47
64
  fail = true;
48
65
  await vi.waitFor(() => expect(view.lastFrame()).toContain('discovery unavailable'));
49
66
  expect(view.lastFrame()).not.toContain(url);
50
- view.stdin.write('p');
67
+ view.stdin.write('o');
51
68
  expect(onOpenPr).not.toHaveBeenCalled();
52
- } finally { view.unmount(); }
69
+ } finally {
70
+ view.unmount();
71
+ }
53
72
  });
54
73
 
55
74
  it('refreshes a replaced or cleared association without retaining the old URL', async () => {
56
75
  const first = 'https://github.com/team/repo/pull/123';
57
76
  const next = 'https://git.example.org/team/repo/-/merge_requests/24';
58
- let association: ISupervisedViewRow['pr'] = { url: first, host: 'github.com', number: 123, kind: 'pull' };
59
- const view = render(<SupervisedSessionView refreshMs={20} loadRows={async () => [
60
- { ...FIRST, ...(association ? { pr: association } : {}) },
61
- ]} />);
77
+ let association: ISupervisedViewRow['pr'] = {
78
+ url: first,
79
+ host: 'github.com',
80
+ number: 123,
81
+ kind: 'pull',
82
+ };
83
+ const view = render(
84
+ <SupervisedSessionView
85
+ refreshMs={20}
86
+ loadRows={async () => [{ ...FIRST, ...(association ? { pr: association } : {}) }]}
87
+ />,
88
+ );
62
89
  try {
63
90
  await vi.waitFor(() => expect(view.lastFrame()).toContain(first));
64
91
  association = { url: next, host: 'git.example.org', number: 24, kind: 'merge-request' };
@@ -66,7 +93,9 @@ describe('supervised session view', () => {
66
93
  expect(view.lastFrame()).not.toContain(first);
67
94
  association = undefined;
68
95
  await vi.waitFor(() => expect(view.lastFrame()).not.toContain(next));
69
- } finally { view.unmount(); }
96
+ } finally {
97
+ view.unmount();
98
+ }
70
99
  });
71
100
  it('toggles verified directory grouping without losing selection or inventing unknown paths', async () => {
72
101
  const rows = [
@@ -94,7 +123,9 @@ describe('supervised session view', () => {
94
123
  it('announces directory groups and their selection in screen-reader mode', async () => {
95
124
  const view = render(
96
125
  <ScreenReaderProvider enabled>
97
- <SupervisedSessionView loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]} />
126
+ <SupervisedSessionView
127
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]}
128
+ />
98
129
  </ScreenReaderProvider>,
99
130
  );
100
131
  try {
@@ -111,13 +142,19 @@ describe('supervised session view', () => {
111
142
  });
112
143
 
113
144
  it('keeps long directory headings within a narrow terminal viewport', async () => {
114
- const view = render(<SupervisedSessionView loadRows={async () => [
115
- { ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
116
- ]} />);
145
+ const view = render(
146
+ <SupervisedSessionView
147
+ loadRows={async () => [
148
+ { ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
149
+ ]}
150
+ />,
151
+ );
117
152
  try {
118
153
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
119
154
  view.stdout.emit('resize');
120
- await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`));
155
+ await vi.waitFor(() =>
156
+ expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`),
157
+ );
121
158
  view.stdin.write('g');
122
159
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
123
160
  expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
@@ -127,10 +164,14 @@ describe('supervised session view', () => {
127
164
  });
128
165
 
129
166
  it('distinguishes directory groups with a shared prefix in a narrow terminal', async () => {
130
- const view = render(<SupervisedSessionView loadRows={async () => [
131
- { ...FIRST, cwd: '/projects/alpha' },
132
- { ...THIRD, cwd: '/projects/beta' },
133
- ]} />);
167
+ const view = render(
168
+ <SupervisedSessionView
169
+ loadRows={async () => [
170
+ { ...FIRST, cwd: '/projects/alpha' },
171
+ { ...THIRD, cwd: '/projects/beta' },
172
+ ]}
173
+ />,
174
+ );
134
175
  try {
135
176
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
136
177
  view.stdout.emit('resize');
@@ -144,10 +185,14 @@ describe('supervised session view', () => {
144
185
  });
145
186
 
146
187
  it('shows differing parent directories when project basenames match at 20 columns', async () => {
147
- const view = render(<SupervisedSessionView loadRows={async () => [
148
- { ...FIRST, cwd: '/projects/alpha/app' },
149
- { ...THIRD, cwd: '/projects/beta/app' },
150
- ]} />);
188
+ const view = render(
189
+ <SupervisedSessionView
190
+ loadRows={async () => [
191
+ { ...FIRST, cwd: '/projects/alpha/app' },
192
+ { ...THIRD, cwd: '/projects/beta/app' },
193
+ ]}
194
+ />,
195
+ );
151
196
  try {
152
197
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
153
198
  view.stdout.emit('resize');
@@ -161,10 +206,14 @@ describe('supervised session view', () => {
161
206
  });
162
207
 
163
208
  it('keeps the differing path portion visible when long parent names share a prefix', async () => {
164
- const view = render(<SupervisedSessionView loadRows={async () => [
165
- { ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
166
- { ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
167
- ]} />);
209
+ const view = render(
210
+ <SupervisedSessionView
211
+ loadRows={async () => [
212
+ { ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
213
+ { ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
214
+ ]}
215
+ />,
216
+ );
168
217
  try {
169
218
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
170
219
  view.stdout.emit('resize');
@@ -178,10 +227,14 @@ describe('supervised session view', () => {
178
227
  });
179
228
 
180
229
  it('keeps the distinguishing wide character visible in a 20-column terminal', async () => {
181
- const view = render(<SupervisedSessionView loadRows={async () => [
182
- { ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
183
- { ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
184
- ]} />);
230
+ const view = render(
231
+ <SupervisedSessionView
232
+ loadRows={async () => [
233
+ { ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
234
+ { ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
235
+ ]}
236
+ />,
237
+ );
185
238
  try {
186
239
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
187
240
  view.stdout.emit('resize');
@@ -196,9 +249,11 @@ describe('supervised session view', () => {
196
249
  });
197
250
 
198
251
  it('escapes layout controls in verified directory headings', async () => {
199
- const view = render(<SupervisedSessionView loadRows={async () => [
200
- { ...FIRST, cwd: '/projects/line\nbreak/app' },
201
- ]} />);
252
+ const view = render(
253
+ <SupervisedSessionView
254
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/line\nbreak/app' }]}
255
+ />,
256
+ );
202
257
  try {
203
258
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
204
259
  view.stdin.write('g');
@@ -214,10 +269,12 @@ describe('supervised session view', () => {
214
269
  it('clears a pending screen-reader number before directory regrouping', async () => {
215
270
  const view = render(
216
271
  <ScreenReaderProvider enabled>
217
- <SupervisedSessionView loadRows={async () => [
218
- { ...FIRST, cwd: '/projects/z' },
219
- { ...THIRD, cwd: '/projects/a' },
220
- ]} />
272
+ <SupervisedSessionView
273
+ loadRows={async () => [
274
+ { ...FIRST, cwd: '/projects/z' },
275
+ { ...THIRD, cwd: '/projects/a' },
276
+ ]}
277
+ />
221
278
  </ScreenReaderProvider>,
222
279
  );
223
280
  try {
@@ -235,7 +292,9 @@ describe('supervised session view', () => {
235
292
  });
236
293
 
237
294
  it('filters by an observed group without treating dead or unverified rows as idle', async () => {
238
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />);
295
+ const view = render(
296
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />,
297
+ );
239
298
  try {
240
299
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
241
300
  expect(view.lastFrame()).toContain('State: idle');
@@ -248,14 +307,19 @@ describe('supervised session view', () => {
248
307
 
249
308
  it('shows an observed loop eligibility countdown without changing idle into a process state', async () => {
250
309
  const nextLoopAt = new Date(Date.now() + 1_200).toISOString();
251
- const view = render(<SupervisedSessionView
252
- loadRows={async () => [{ ...THIRD, nextLoopAt }]}
253
- stateFilter="idle" refreshMs={100}
254
- />);
310
+ const view = render(
311
+ <SupervisedSessionView
312
+ loadRows={async () => [{ ...THIRD, nextLoopAt }]}
313
+ stateFilter="idle"
314
+ refreshMs={100}
315
+ />,
316
+ );
255
317
  try {
256
318
  await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
257
319
  expect(view.lastFrame()).toContain('activity idle');
258
- await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), { timeout: 3_000 });
320
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), {
321
+ timeout: 3_000,
322
+ });
259
323
  } finally {
260
324
  view.unmount();
261
325
  }
@@ -276,8 +340,24 @@ describe('supervised session view', () => {
276
340
  }
277
341
  });
278
342
 
343
+ it('never renders loaded rows without a selection', async () => {
344
+ const view = render(
345
+ <SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
346
+ );
347
+ try {
348
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
349
+ const withRows = view.frames.filter((frame) => frame.includes('Morning review'));
350
+ expect(withRows.length).toBeGreaterThan(0);
351
+ for (const frame of withRows) expect(frame).toContain(`Selected ${THIRD.id}`);
352
+ } finally {
353
+ view.unmount();
354
+ }
355
+ });
356
+
279
357
  it('shows a verified human name while preserving the exact selected ID', async () => {
280
- const view = render(<SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />);
358
+ const view = render(
359
+ <SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
360
+ );
281
361
  try {
282
362
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
283
363
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
@@ -305,8 +385,13 @@ describe('supervised session view', () => {
305
385
 
306
386
  it('keeps stop cancellation separate from starting a session', async () => {
307
387
  const start = vi.fn(async () => SECOND.id);
308
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onStart={start}
309
- onStop={async () => undefined} />);
388
+ const view = render(
389
+ <SupervisedSessionView
390
+ loadRows={async () => [FIRST]}
391
+ onStart={start}
392
+ onStop={async () => undefined}
393
+ />,
394
+ );
310
395
  try {
311
396
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
312
397
  view.stdin.write('s');
@@ -320,7 +405,9 @@ describe('supervised session view', () => {
320
405
  });
321
406
 
322
407
  it('can start from an empty view and hides private launch errors', async () => {
323
- const start = vi.fn().mockRejectedValueOnce(new Error('/private/token-path'))
408
+ const start = vi
409
+ .fn()
410
+ .mockRejectedValueOnce(new Error('/private/token-path'))
324
411
  .mockResolvedValueOnce(SECOND.id);
325
412
  const view = render(<SupervisedSessionView loadRows={async () => []} onStart={start} />);
326
413
  try {
@@ -338,10 +425,22 @@ describe('supervised session view', () => {
338
425
 
339
426
  it('does not stop a row that leaves the selected state while confirmation is open', async () => {
340
427
  let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
341
- const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => { finishRefresh = resolve; });
342
- const loadRows = vi.fn().mockResolvedValueOnce([THIRD]).mockImplementation(() => refresh);
428
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
429
+ finishRefresh = resolve;
430
+ });
431
+ const loadRows = vi
432
+ .fn()
433
+ .mockResolvedValueOnce([THIRD])
434
+ .mockImplementation(() => refresh);
343
435
  const stop = vi.fn(async () => undefined);
344
- const view = render(<SupervisedSessionView loadRows={loadRows} onStop={stop} stateFilter="idle" refreshMs={100} />);
436
+ const view = render(
437
+ <SupervisedSessionView
438
+ loadRows={loadRows}
439
+ onStop={stop}
440
+ stateFilter="idle"
441
+ refreshMs={100}
442
+ />,
443
+ );
345
444
  try {
346
445
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
347
446
  view.stdin.write('s');
@@ -359,16 +458,36 @@ describe('supervised session view', () => {
359
458
 
360
459
  it('lists every active key in help even in a narrow, crowded terminal', async () => {
361
460
  const rows = Array.from({ length: 18 }, (_, index) => ({
362
- ...FIRST, id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
461
+ ...FIRST,
462
+ id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
363
463
  }));
364
- const view = render(<SupervisedSessionView loadRows={async () => rows} onStart={async () => SECOND.id} />);
464
+ const view = render(
465
+ <SupervisedSessionView
466
+ loadRows={async () => rows}
467
+ onStart={async () => SECOND.id}
468
+ onAttach={vi.fn()}
469
+ onOpenPr={async () => undefined}
470
+ />,
471
+ );
365
472
  try {
366
473
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
367
474
  view.stdout.emit('resize');
368
475
  await vi.waitFor(() => expect(view.lastFrame()).toContain('18 supervised'));
369
476
  view.stdin.write('?');
370
477
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Keys:'));
371
- for (const key of ['↑/↓ Select', 's Request stop', 'g Group state/dir', 'n New session', 'y Confirm stop', 'n/Esc Cancel stop', 'q/Esc/Ctrl+C Close', '? Toggle help']) {
478
+ for (const key of [
479
+ '↑/↓ Select',
480
+ 's Request stop',
481
+ 'g Group state/dir',
482
+ 'n New session',
483
+ 'a Attach (drive)',
484
+ 'p Peek (read only)',
485
+ 'o Open linked PR',
486
+ 'y Confirm',
487
+ 'n/Esc Cancel',
488
+ 'q/Esc/Ctrl+C Close',
489
+ '? Toggle help',
490
+ ]) {
372
491
  expect(view.lastFrame()).toContain(key);
373
492
  }
374
493
  expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
@@ -389,7 +508,9 @@ describe('supervised session view', () => {
389
508
  });
390
509
 
391
510
  it('shows a navigable global background list without inventing completion or attach', async () => {
392
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />);
511
+ const view = render(
512
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />,
513
+ );
393
514
  try {
394
515
  await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
395
516
  expect(view.lastFrame()).toContain('working');
@@ -404,7 +525,9 @@ describe('supervised session view', () => {
404
525
 
405
526
  it('never overlaps refreshes and aborts an in-flight probe when closed', async () => {
406
527
  let resolveSecond: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
407
- const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => { resolveSecond = resolve; });
528
+ const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
529
+ resolveSecond = resolve;
530
+ });
408
531
  const signals: AbortSignal[] = [];
409
532
  const loadRows = vi.fn((signal: AbortSignal) => {
410
533
  signals.push(signal);
@@ -426,18 +549,37 @@ describe('supervised session view', () => {
426
549
  });
427
550
 
428
551
  it('keeps the selected session when activity changes reorder the groups', async () => {
429
- const loadRows = vi.fn()
552
+ // The reordering refresh is released only after the selection moved, so the test cannot race
553
+ // the refresh timer against the keypress.
554
+ let releaseReorder: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
555
+ const reordered = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
556
+ releaseReorder = resolve;
557
+ });
558
+ const loadRows = vi
559
+ .fn()
430
560
  .mockResolvedValueOnce([FIRST, SECOND])
431
- .mockResolvedValue([
432
- { ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
433
- { ...SECOND, liveness: 'alive', control: 'available', activity: 'working' },
434
- ]);
435
- const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={150} />);
561
+ .mockImplementation(() => reordered);
562
+ const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={20} />);
436
563
  try {
437
- await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
564
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
438
565
  view.stdin.write('\x1B[B');
439
566
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
440
567
  await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
568
+ releaseReorder?.([
569
+ { ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
570
+ { ...SECOND, liveness: 'alive', control: 'available', activity: 'working', generation: 'secondGenerationValue1' },
571
+ ]);
572
+ // Wait for the reordered list itself: SECOND listed under `working:`, FIRST under `dead:`.
573
+ const rowLine = (lines: readonly string[], id: string): number =>
574
+ lines.findIndex((line) => line.includes(id) && !line.startsWith('Selected'));
575
+ await vi.waitFor(() => {
576
+ const lines = (view.lastFrame() ?? '').split('\n');
577
+ const working = lines.indexOf('working:');
578
+ const dead = lines.indexOf('dead:');
579
+ expect(working).toBeGreaterThanOrEqual(0);
580
+ expect(rowLine(lines, SECOND.id)).toBe(working + 1);
581
+ expect(rowLine(lines, FIRST.id)).toBe(dead + 1);
582
+ });
441
583
  expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`);
442
584
  } finally {
443
585
  view.unmount();
@@ -494,7 +636,9 @@ describe('supervised session view', () => {
494
636
  ...FIRST,
495
637
  id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
496
638
  }));
497
- const view = render(<SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />);
639
+ const view = render(
640
+ <SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />,
641
+ );
498
642
  try {
499
643
  Object.defineProperty(view.stdout, 'columns', { value: 40 });
500
644
  view.stdout.emit('resize');
@@ -512,7 +656,12 @@ describe('supervised session view', () => {
512
656
 
513
657
  it('identifies the selected stop target even at 20 columns with matching UUID prefixes', async () => {
514
658
  const similar = { ...FIRST, id: `${FIRST.id.slice(0, -8)}ffffffff` };
515
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, similar]} onStop={async () => undefined} />);
659
+ const view = render(
660
+ <SupervisedSessionView
661
+ loadRows={async () => [FIRST, similar]}
662
+ onStop={async () => undefined}
663
+ />,
664
+ );
516
665
  try {
517
666
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
518
667
  view.stdin.write('\x1B[B');
@@ -530,8 +679,15 @@ describe('supervised session view', () => {
530
679
 
531
680
  it('requires confirmation and stops only the selected owner-controllable row', async () => {
532
681
  let finishStop: (() => void) | undefined;
533
- const stop = vi.fn(() => new Promise<void>((resolve) => { finishStop = resolve; }));
534
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />);
682
+ const stop = vi.fn(
683
+ () =>
684
+ new Promise<void>((resolve) => {
685
+ finishStop = resolve;
686
+ }),
687
+ );
688
+ const view = render(
689
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
690
+ );
535
691
  try {
536
692
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
537
693
  view.stdin.write('s');
@@ -543,7 +699,7 @@ describe('supervised session view', () => {
543
699
  view.stdin.write('s');
544
700
  await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
545
701
  view.stdin.write('y');
546
- await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id));
702
+ await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id, FIRST.generation));
547
703
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Stopping'));
548
704
  expect(view.lastFrame()).toContain('Stop in progress; wait for result.');
549
705
  expect(view.lastFrame()).not.toContain('q/Esc Close');
@@ -561,12 +717,17 @@ describe('supervised session view', () => {
561
717
 
562
718
  it('refuses a stop when polling removes control while confirmation is open', async () => {
563
719
  let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
564
- const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => { finishRefresh = resolve; });
565
- const loadRows = vi.fn()
720
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
721
+ finishRefresh = resolve;
722
+ });
723
+ const loadRows = vi
724
+ .fn()
566
725
  .mockResolvedValueOnce([FIRST])
567
726
  .mockImplementation(() => refresh);
568
727
  const stop = vi.fn(async () => undefined);
569
- const view = render(<SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />);
728
+ const view = render(
729
+ <SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
730
+ );
570
731
  try {
571
732
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
572
733
  view.stdin.write('s');
@@ -583,8 +744,12 @@ describe('supervised session view', () => {
583
744
  });
584
745
 
585
746
  it('never offers stop for unavailable rows and leaves failures visible', async () => {
586
- const stop = vi.fn(async () => { throw new Error('private path must not be shown'); });
587
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />);
747
+ const stop = vi.fn(async () => {
748
+ throw new Error('private path must not be shown');
749
+ });
750
+ const view = render(
751
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
752
+ );
588
753
  try {
589
754
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
590
755
  view.stdin.write('\x1B[B');
@@ -605,6 +770,21 @@ describe('supervised session view', () => {
605
770
  }
606
771
  });
607
772
 
773
+ it('selects when a typed number and Enter arrive in one input chunk', async () => {
774
+ const view = render(
775
+ <ScreenReaderProvider enabled>
776
+ <SupervisedSessionView loadRows={async () => [FIRST, THIRD]} />
777
+ </ScreenReaderProvider>,
778
+ );
779
+ try {
780
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
781
+ view.stdin.write('2\r');
782
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
783
+ } finally {
784
+ view.unmount();
785
+ }
786
+ });
787
+
608
788
  it('uses the same confirmed stop action after screen-reader selection', async () => {
609
789
  const stop = vi.fn(async () => undefined);
610
790
  const view = render(
@@ -621,7 +801,198 @@ describe('supervised session view', () => {
621
801
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stop ${THIRD.id}?`));
622
802
  expect(view.lastFrame()).not.toContain('Enter selection');
623
803
  view.stdin.write('y');
624
- await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id));
804
+ await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id, THIRD.generation));
805
+ } finally {
806
+ view.unmount();
807
+ }
808
+ });
809
+ it('refuses a stop when the session restarts under the same id while confirmation is open', async () => {
810
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
811
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
812
+ finishRefresh = resolve;
813
+ });
814
+ const loadRows = vi
815
+ .fn()
816
+ .mockResolvedValueOnce([FIRST])
817
+ .mockImplementation(() => refresh);
818
+ const stop = vi.fn(async () => undefined);
819
+ const view = render(
820
+ <SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
821
+ );
822
+ try {
823
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
824
+ view.stdin.write('s');
825
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
826
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
827
+ finishRefresh?.([{ ...FIRST, generation: 'restartedGeneration001' }]);
828
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
829
+ view.stdin.write('y');
830
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
831
+ expect(stop).not.toHaveBeenCalled();
832
+ } finally {
833
+ view.unmount();
834
+ }
835
+ });
836
+
837
+ it('never offers stop or PR open for a row without a verified generation', async () => {
838
+ const stop = vi.fn(async () => undefined);
839
+ const onOpenPr = vi.fn(async () => undefined);
840
+ const { generation: _generation, ...unbound } = FIRST;
841
+ const row: ISupervisedViewRow = {
842
+ ...unbound,
843
+ name: 'Unbound name',
844
+ pr: { url: 'https://github.com/team/repo/pull/9', host: 'github.com', number: 9, kind: 'pull' },
845
+ };
846
+ const view = render(
847
+ <SupervisedSessionView loadRows={async () => [row]} onStop={stop} onOpenPr={onOpenPr} />,
848
+ );
849
+ try {
850
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
851
+ expect(view.lastFrame()).toContain('unverified:');
852
+ expect(view.lastFrame()).not.toContain('Unbound name');
853
+ expect(view.lastFrame()).not.toContain('github.com');
854
+ view.stdin.write('s');
855
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
856
+ expect(view.lastFrame()).not.toContain('y Yes / n No');
857
+ view.stdin.write('o');
858
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('No verified PR link'));
859
+ expect(stop).not.toHaveBeenCalled();
860
+ expect(onOpenPr).not.toHaveBeenCalled();
861
+ } finally {
862
+ view.unmount();
863
+ }
864
+ });
865
+ it('attaches or peeks at a verified row only after a confirmation bound to its generation', async () => {
866
+ for (const [key, mode, wording] of [
867
+ ['a', 'drive', 'drive (send prompts, answer its questions)'],
868
+ ['p', 'observe', 'observe (read only)'],
869
+ ] as const) {
870
+ const onAttach = vi.fn();
871
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
872
+ try {
873
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
874
+ expect(view.lastFrame()).toContain('a Attach');
875
+ expect(view.lastFrame()).toContain('p Peek');
876
+ view.stdin.write(key);
877
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(wording));
878
+ expect(view.lastFrame()).toContain('y Yes / n No');
879
+ view.stdin.write('n');
880
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
881
+ expect(onAttach).not.toHaveBeenCalled();
882
+ view.stdin.write(key);
883
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
884
+ view.stdin.write('y');
885
+ await vi.waitFor(() =>
886
+ expect(onAttach).toHaveBeenCalledExactlyOnceWith({
887
+ id: FIRST.id, generation: FIRST.generation, mode, groupByDirectory: false,
888
+ }),
889
+ );
890
+ } finally {
891
+ view.unmount();
892
+ }
893
+ }
894
+ });
895
+
896
+ it('refuses an attach when the row restarts or leaves control while confirmation is open', async () => {
897
+ for (const replacement of [
898
+ { ...FIRST, generation: 'restartedGeneration001' },
899
+ { ...FIRST, control: 'unavailable' as const, activity: 'unknown' as const },
900
+ ]) {
901
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
902
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
903
+ finishRefresh = resolve;
904
+ });
905
+ const loadRows = vi.fn().mockResolvedValueOnce([FIRST]).mockImplementation(() => refresh);
906
+ const onAttach = vi.fn();
907
+ const view = render(<SupervisedSessionView loadRows={loadRows} onAttach={onAttach} refreshMs={100} />);
908
+ try {
909
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
910
+ view.stdin.write('a');
911
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
912
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
913
+ finishRefresh?.([replacement]);
914
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
915
+ view.stdin.write('y');
916
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
917
+ expect(onAttach).not.toHaveBeenCalled();
918
+ } finally {
919
+ view.unmount();
920
+ }
921
+ }
922
+ });
923
+
924
+ it('never offers attach or peek on dead, unknown or unverified rows', async () => {
925
+ const { generation: _generation, ...unbound } = FIRST;
926
+ for (const row of [
927
+ SECOND,
928
+ { ...FIRST, liveness: 'unknown' as const, control: 'unavailable' as const, activity: 'unknown' as const },
929
+ unbound,
930
+ ]) {
931
+ const onAttach = vi.fn();
932
+ const view = render(<SupervisedSessionView loadRows={async () => [row]} onAttach={onAttach} />);
933
+ try {
934
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${row.id}`));
935
+ expect(view.lastFrame()).not.toContain('a Attach');
936
+ expect(view.lastFrame()).not.toContain('p Peek');
937
+ for (const key of ['a', 'p']) {
938
+ view.stdin.write(key);
939
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
940
+ expect(view.lastFrame()).not.toContain('y Yes / n No');
941
+ }
942
+ expect(onAttach).not.toHaveBeenCalled();
943
+ } finally {
944
+ view.unmount();
945
+ }
946
+ }
947
+ });
948
+
949
+ it('names the whole session id and the role in screen-reader mode', async () => {
950
+ const onAttach = vi.fn();
951
+ const view = render(
952
+ <ScreenReaderProvider enabled>
953
+ <SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />
954
+ </ScreenReaderProvider>,
955
+ );
956
+ try {
957
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
958
+ expect(view.lastFrame()).toContain('a Attach; p Peek;');
959
+ view.stdin.write('p');
960
+ await vi.waitFor(() =>
961
+ expect(view.lastFrame()).toContain(`Peek at ${FIRST.id} to observe (read only)? y Yes / n No`),
962
+ );
963
+ } finally {
964
+ view.unmount();
965
+ }
966
+ });
967
+ it('reopens on the row and grouping it was left with', async () => {
968
+ const view = render(
969
+ <SupervisedSessionView
970
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, { ...THIRD, cwd: '/projects/beta' }]}
971
+ initialSelectedId={THIRD.id}
972
+ initialGroupByDirectory
973
+ />,
974
+ );
975
+ try {
976
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
977
+ expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha');
978
+ } finally {
979
+ view.unmount();
980
+ }
981
+ });
982
+
983
+ it('hands back the grouping with a confirmed attach', async () => {
984
+ const onAttach = vi.fn();
985
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
986
+ try {
987
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
988
+ view.stdin.write('g');
989
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Directory: unverified'));
990
+ view.stdin.write('p');
991
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
992
+ view.stdin.write('y');
993
+ await vi.waitFor(() => expect(onAttach).toHaveBeenCalledExactlyOnceWith({
994
+ id: FIRST.id, generation: FIRST.generation, mode: 'observe', groupByDirectory: true,
995
+ }));
625
996
  } finally {
626
997
  view.unmount();
627
998
  }