@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.
- package/CHANGELOG.md +893 -0
- package/README.md +34 -11
- package/dist/node/index.d.ts +205 -55
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +21 -21
- package/dist/node/index.js.map +1 -1
- package/package.json +19 -23
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +73 -23
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +568 -161
- package/src/TuiInteractionChannel.ts +17 -54
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +561 -82
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
- package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
- package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
- package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
- package/src/__tests__/helpers/terminal-profiles.ts +4 -4
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
- package/src/__tests__/palette-consistency.test.ts +0 -1
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +11 -3
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
- package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
- package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
- package/src/__tests__/render-channel-options.test.ts +6 -6
- package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
- package/src/__tests__/screen-reader-menus.test.tsx +1 -1
- package/src/__tests__/session-picker.test.tsx +93 -0
- package/src/__tests__/status-bar.test.tsx +8 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
- package/src/__tests__/tui-interaction-queues.test.ts +10 -0
- package/src/__tests__/tui-state-manager.test.ts +31 -0
- package/src/__tests__/wire-tui-channel.test.ts +1457 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-view-model.ts +6 -2
- package/src/attached-session-connection.ts +14 -0
- package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
- package/src/hooks/useAppController.ts +6 -2
- package/src/hooks/useAppInputBindings.ts +20 -2
- package/src/hooks/useAppInteractionState.ts +3 -0
- package/src/hooks/useAppOverlays.ts +20 -4
- package/src/hooks/useNumberedSelection.ts +19 -2
- package/src/hooks/useTuiChannel.ts +13 -1
- package/src/index.ts +19 -3
- package/src/render.tsx +218 -27
- package/src/short-session-id.ts +9 -0
- package/src/slash-command-input.ts +11 -0
- package/src/tui-app-channel-port.ts +29 -1
- package/src/tui-channel-options.ts +11 -2
- package/src/tui-interaction-queues.ts +9 -1
- package/src/tui-session-event-projector.ts +12 -3
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +25 -5
- package/src/types.ts +3 -1
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +963 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
- package/dist/node/index.cjs +0 -32
- package/dist/node/index.d.cts +0 -1066
- 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 {
|
|
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?: {
|
|
20
|
-
readonly
|
|
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
|
-
|
|
27
|
-
readonly
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
50
|
-
|
|
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(
|
|
55
|
-
(a
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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 (
|
|
67
|
-
|
|
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(
|
|
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
|
|
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) =>
|
|
80
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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 [
|
|
247
|
+
const [chosenId, setSelectedId] = useState<string | undefined>(initialSelectedId);
|
|
141
248
|
const [showHelp, setShowHelp] = useState(false);
|
|
142
|
-
const [groupByDirectory, setGroupByDirectory] = useState(
|
|
143
|
-
|
|
144
|
-
const [
|
|
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'>(
|
|
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<
|
|
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(
|
|
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 = [
|
|
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
|
|
314
|
+
? row.cwd === undefined
|
|
315
|
+
? 'Directory: unverified'
|
|
167
316
|
: (() => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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) =>
|
|
194
|
-
|
|
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)
|
|
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 () => {
|
|
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
|
-
|
|
217
|
-
|
|
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 (
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 === '
|
|
525
|
+
if (input === 'o' && onOpenPr !== undefined) {
|
|
274
526
|
const row = ordered.find((candidate) => candidate.id === selectedId);
|
|
275
|
-
if (status !== 'ready' || row
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
547
|
+
if (status !== 'ready' || !isControllable(row)) {
|
|
291
548
|
setStopStatus('unavailable');
|
|
292
549
|
} else {
|
|
293
550
|
setStopStatus('idle');
|
|
294
|
-
|
|
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(
|
|
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:
|
|
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: () => {
|
|
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 =
|
|
320
|
-
|
|
321
|
-
const selectedLoopStatus =
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
selectedRow.
|
|
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
|
-
...(
|
|
335
|
-
'
|
|
336
|
-
'
|
|
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 =
|
|
346
|
-
+
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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(
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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}>
|
|
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' &&
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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 &&
|
|
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' &&
|
|
403
|
-
|
|
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' &&
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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' &&
|
|
418
|
-
|
|
419
|
-
|
|
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 &&
|
|
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<
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
840
|
+
onAttach={(request) => {
|
|
841
|
+
attach = request;
|
|
842
|
+
}}
|
|
843
|
+
filteredByCwd={options.filteredByCwd}
|
|
844
|
+
filteredByName={options.filteredByName}
|
|
445
845
|
filteredByPr={options.filteredByPr}
|
|
446
|
-
stateFilter={options.stateFilter}
|
|
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
|
}
|