@robota-sdk/agent-ui-terminal 3.0.0-beta.83 → 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 +35 -0
- package/README.md +32 -13
- package/dist/node/index.d.ts +18 -3
- 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 +14 -13
- package/src/PermissionPrompt.tsx +23 -6
- package/src/SupervisedSessionView.tsx +101 -16
- package/src/TuiInteractionChannel.ts +9 -1
- package/src/__tests__/SupervisedSessionView.test.tsx +108 -0
- package/src/__tests__/helpers/terminal-profiles.ts +4 -4
- 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/trust-prompt.ptytest.ts +134 -0
- 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 +12 -0
- package/src/index.ts +2 -0
- package/src/tui-interaction-queues.ts +9 -1
- package/src/tui-session-event-projector.ts +12 -1
- package/src/tui-state-manager.ts +8 -2
- package/src/types.ts +3 -1
- package/src/wire-tui-channel.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-ui-terminal",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.84",
|
|
4
4
|
"description": "Terminal UI package (React + Ink) for the Robota SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
},
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
28
|
-
"url": "https://github.com/woojubb/robota.git"
|
|
28
|
+
"url": "https://github.com/woojubb/robota.git",
|
|
29
|
+
"directory": "packages/agent-ui-terminal"
|
|
29
30
|
},
|
|
30
|
-
"homepage": "https://robota.io/",
|
|
31
|
+
"homepage": "https://docs.robota.io/en/packages/agent-ui-terminal/",
|
|
31
32
|
"bugs": {
|
|
32
33
|
"url": "https://github.com/woojubb/robota/issues"
|
|
33
34
|
},
|
|
@@ -46,15 +47,15 @@
|
|
|
46
47
|
"marked-terminal": "^7.3.0",
|
|
47
48
|
"react": "19.2.8",
|
|
48
49
|
"string-width": "^8.2.2",
|
|
49
|
-
"@robota-sdk/agent-core": "3.0.0-beta.
|
|
50
|
-
"@robota-sdk/agent-framework": "3.0.0-beta.
|
|
51
|
-
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.
|
|
52
|
-
"@robota-sdk/agent-interface-command": "3.0.0-beta.
|
|
53
|
-
"@robota-sdk/agent-interface-execution": "3.0.0-beta.
|
|
54
|
-
"@robota-sdk/agent-interface-session": "3.0.0-beta.
|
|
55
|
-
"@robota-sdk/agent-interface-transport": "3.0.0-beta.
|
|
56
|
-
"@robota-sdk/agent-
|
|
57
|
-
"@robota-sdk/agent-
|
|
50
|
+
"@robota-sdk/agent-core": "3.0.0-beta.84",
|
|
51
|
+
"@robota-sdk/agent-framework": "3.0.0-beta.84",
|
|
52
|
+
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.84",
|
|
53
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.84",
|
|
54
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.84",
|
|
55
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.84",
|
|
56
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.84",
|
|
57
|
+
"@robota-sdk/agent-interface-tui": "3.0.0-beta.84",
|
|
58
|
+
"@robota-sdk/agent-transport": "3.0.0-beta.84"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
"tsx": "^4.23.1",
|
|
66
67
|
"typescript": "^6.0.3",
|
|
67
68
|
"vitest": "^3.2.6",
|
|
68
|
-
"@robota-sdk/agent-command": "3.0.0-beta.
|
|
69
|
+
"@robota-sdk/agent-command": "3.0.0-beta.84"
|
|
69
70
|
},
|
|
70
71
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
71
72
|
"publishConfig": {
|
package/src/PermissionPrompt.tsx
CHANGED
|
@@ -153,11 +153,19 @@ export default function PermissionPrompt({
|
|
|
153
153
|
['confirm', 'Confirm'],
|
|
154
154
|
]);
|
|
155
155
|
|
|
156
|
+
// Issue #3288 §1: a background agent's own request, read out/shown before the tool name.
|
|
157
|
+
const backgroundAgentLabel =
|
|
158
|
+
request.requester?.kind === 'background-agent' ? request.requester.label : undefined;
|
|
159
|
+
|
|
156
160
|
if (screenReader) {
|
|
157
161
|
return (
|
|
158
162
|
<NumberedList
|
|
159
163
|
title={SCREEN_READER_LABELS.permissionRequired}
|
|
160
|
-
description={`${
|
|
164
|
+
description={`${
|
|
165
|
+
backgroundAgentLabel !== undefined
|
|
166
|
+
? `Background agent ${backgroundAgentLabel} wants to run `
|
|
167
|
+
: ''
|
|
168
|
+
}${request.toolName} — ${formatArgs(request.toolArgs)}${
|
|
161
169
|
request.requestedByPeer !== undefined
|
|
162
170
|
? ` (requested by another session: ${request.requestedByPeer})`
|
|
163
171
|
: ''
|
|
@@ -182,12 +190,21 @@ export default function PermissionPrompt({
|
|
|
182
190
|
</Text>
|
|
183
191
|
{armed ? null : <Text dimColor> keys answer in a moment</Text>}
|
|
184
192
|
</Box>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
{
|
|
193
|
+
{backgroundAgentLabel !== undefined ? (
|
|
194
|
+
<Text color={palette.text.warning}>
|
|
195
|
+
Background agent <Text bold>{backgroundAgentLabel}</Text> wants to run{' '}
|
|
196
|
+
<Text color={palette.text.accent} bold>
|
|
197
|
+
{request.toolName}
|
|
198
|
+
</Text>
|
|
199
|
+
</Text>
|
|
200
|
+
) : (
|
|
201
|
+
<Text>
|
|
202
|
+
Tool:{' '}
|
|
203
|
+
<Text color={palette.text.accent} bold>
|
|
204
|
+
{request.toolName}
|
|
205
|
+
</Text>
|
|
189
206
|
</Text>
|
|
190
|
-
|
|
207
|
+
)}
|
|
191
208
|
<Text dimColor> {formatArgs(request.toolArgs)}</Text>
|
|
192
209
|
{request.requestedByPeer !== undefined ? (
|
|
193
210
|
<Text color={palette.text.warning}>
|
|
@@ -49,13 +49,28 @@ const ROLE: Readonly<Record<ISupervisedAttachRequest['mode'], string>> = {
|
|
|
49
49
|
observe: 'observe (read only)',
|
|
50
50
|
};
|
|
51
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[];
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
export interface ISupervisedSessionViewProps {
|
|
53
66
|
readonly loadRows: (signal: AbortSignal) => Promise<readonly ISupervisedViewRow[]>;
|
|
54
67
|
/** Called once the user confirmed; the view then closes so the host can attach this terminal. */
|
|
55
68
|
readonly onAttach?: (request: ISupervisedAttachRequest) => void;
|
|
56
69
|
/** Receives the generation the row displayed, so a session restarted under the same id is refused. */
|
|
57
70
|
readonly onStop?: (id: string, generation: string) => Promise<void>;
|
|
58
|
-
readonly onStart?: () => Promise<string>;
|
|
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>;
|
|
59
74
|
readonly onOpenPr?: (id: string, url: string, generation: string) => Promise<void>;
|
|
60
75
|
readonly filteredByCwd?: boolean;
|
|
61
76
|
readonly filteredByName?: boolean;
|
|
@@ -68,6 +83,17 @@ export interface ISupervisedSessionViewProps {
|
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
const GROUP_ORDER = ['needs-input', 'working', 'idle', 'unknown', 'unverified', 'dead'] as const;
|
|
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
|
+
}
|
|
71
97
|
type TGroup = (typeof GROUP_ORDER)[number];
|
|
72
98
|
type TDisplayLine =
|
|
73
99
|
| { readonly kind: 'group'; readonly label: string }
|
|
@@ -200,6 +226,7 @@ export default function SupervisedSessionView({
|
|
|
200
226
|
loadRows,
|
|
201
227
|
onStop,
|
|
202
228
|
onStart,
|
|
229
|
+
startTrustQuestion,
|
|
203
230
|
onOpenPr,
|
|
204
231
|
onAttach,
|
|
205
232
|
filteredByCwd = false,
|
|
@@ -241,12 +268,32 @@ export default function SupervisedSessionView({
|
|
|
241
268
|
'idle',
|
|
242
269
|
);
|
|
243
270
|
const [lastStartedId, setLastStartedId] = useState<string | undefined>();
|
|
271
|
+
const [trustQuestion, setTrustQuestion] = useState<ISupervisedStartTrustQuestion | undefined>();
|
|
244
272
|
const startingRef = useRef(false);
|
|
245
273
|
const [prOpenStatus, setPrOpenStatus] = useState<
|
|
246
274
|
'idle' | 'opening' | 'opened' | 'failed' | 'unavailable'
|
|
247
275
|
>('idle');
|
|
248
276
|
const openingPrRef = useRef(false);
|
|
249
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
|
+
};
|
|
250
297
|
const ordered = useMemo(() => {
|
|
251
298
|
const filtered = rows.filter(
|
|
252
299
|
(row) => stateFilter === undefined || groupOf(row) === stateFilter,
|
|
@@ -362,6 +409,15 @@ export default function SupervisedSessionView({
|
|
|
362
409
|
!(key.ctrl && input === 'c')
|
|
363
410
|
)
|
|
364
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
|
+
}
|
|
365
421
|
if (confirmStopId !== undefined) {
|
|
366
422
|
if (input === 'n' || key.escape) {
|
|
367
423
|
setConfirmStop(undefined);
|
|
@@ -431,20 +487,22 @@ export default function SupervisedSessionView({
|
|
|
431
487
|
return;
|
|
432
488
|
}
|
|
433
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.
|
|
434
495
|
startingRef.current = true;
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
.then((id) => {
|
|
496
|
+
void startTrustQuestion()
|
|
497
|
+
.then((question) => {
|
|
498
|
+
startingRef.current = false;
|
|
439
499
|
if (!mountedRef.current) return;
|
|
440
|
-
|
|
441
|
-
|
|
500
|
+
if (question !== undefined) setTrustQuestion(question);
|
|
501
|
+
else runStart();
|
|
442
502
|
})
|
|
443
503
|
.catch(() => {
|
|
444
|
-
if (mountedRef.current) setStartStatus('failed');
|
|
445
|
-
})
|
|
446
|
-
.finally(() => {
|
|
447
504
|
startingRef.current = false;
|
|
505
|
+
if (mountedRef.current) setStartStatus('failed');
|
|
448
506
|
});
|
|
449
507
|
return;
|
|
450
508
|
}
|
|
@@ -507,7 +565,11 @@ export default function SupervisedSessionView({
|
|
|
507
565
|
});
|
|
508
566
|
|
|
509
567
|
const numbered = useNumberedSelection({
|
|
510
|
-
enabled:
|
|
568
|
+
enabled:
|
|
569
|
+
screenReader &&
|
|
570
|
+
confirmStopId === undefined &&
|
|
571
|
+
trustQuestion === undefined &&
|
|
572
|
+
stopStatus !== 'stopping',
|
|
511
573
|
itemCount: ordered.length,
|
|
512
574
|
cancellable: true,
|
|
513
575
|
repeatable: true,
|
|
@@ -565,6 +627,7 @@ export default function SupervisedSessionView({
|
|
|
565
627
|
(selectedLoopStatus ? 1 : 0) +
|
|
566
628
|
(prOpenStatus === 'idle' ? 0 : 1) +
|
|
567
629
|
(startStatus === 'idle' ? 0 : 1) +
|
|
630
|
+
(trustQuestion === undefined ? 0 : 1) +
|
|
568
631
|
(confirmStopId !== undefined ? (screenReader ? 1 : 2) : stopStatus !== 'idle' ? 1 : 0) +
|
|
569
632
|
(confirmStopId === undefined && attachStatus !== 'idle' ? 1 : 0) +
|
|
570
633
|
1 +
|
|
@@ -580,12 +643,23 @@ export default function SupervisedSessionView({
|
|
|
580
643
|
Math.max(0, displayLines.length - viewport),
|
|
581
644
|
);
|
|
582
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
|
+
);
|
|
583
655
|
const chromeWrap = screenReader ? {} : { wrap: 'truncate-end' as const };
|
|
584
656
|
// Attach and peek are offered only for a row this terminal could actually attach to.
|
|
585
657
|
const attachable = onAttach !== undefined && status === 'ready' && isControllable(selectedRow);
|
|
586
658
|
const footer =
|
|
587
659
|
stopStatus === 'stopping'
|
|
588
660
|
? 'Stop in progress; wait for result.'
|
|
661
|
+
: trustQuestion !== undefined
|
|
662
|
+
? 'Answer the trust question or cancel before closing.'
|
|
589
663
|
: confirmStopId !== undefined
|
|
590
664
|
? `Confirm ${confirmStop?.action === 'attach' ? 'attach' : 'stop'} or cancel before closing.`
|
|
591
665
|
: screenReader
|
|
@@ -617,8 +691,15 @@ export default function SupervisedSessionView({
|
|
|
617
691
|
{status === 'ready' && ordered.length === 0 && (
|
|
618
692
|
<Text {...chromeWrap}>No supervised sessions.</Text>
|
|
619
693
|
)}
|
|
620
|
-
{
|
|
621
|
-
|
|
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) =>
|
|
622
703
|
line.kind === 'group' ? (
|
|
623
704
|
<Text key={`group-${line.label}`} {...chromeWrap}>
|
|
624
705
|
{line.label}
|
|
@@ -652,9 +733,11 @@ export default function SupervisedSessionView({
|
|
|
652
733
|
</Text>
|
|
653
734
|
),
|
|
654
735
|
)}
|
|
655
|
-
{
|
|
656
|
-
|
|
657
|
-
|
|
736
|
+
{trustQuestion === undefined &&
|
|
737
|
+
!screenReader &&
|
|
738
|
+
start + visible.length < displayLines.length && (
|
|
739
|
+
<Text>{displayLines.length - start - visible.length} more below</Text>
|
|
740
|
+
)}
|
|
658
741
|
{selectedId !== undefined && <Text {...chromeWrap}>Selected {selectedId}</Text>}
|
|
659
742
|
{selectedName !== undefined && <Text {...chromeWrap}>Name: {selectedName}</Text>}
|
|
660
743
|
{selectedPr !== undefined && (
|
|
@@ -685,6 +768,7 @@ export default function SupervisedSessionView({
|
|
|
685
768
|
Start failed; check workspace trust or run session start --background for details.
|
|
686
769
|
</Text>
|
|
687
770
|
)}
|
|
771
|
+
{trustQuestion !== undefined && <Text {...chromeWrap}>{TRUST_CHOICES}</Text>}
|
|
688
772
|
{confirmStopId !== undefined &&
|
|
689
773
|
(screenReader ? (
|
|
690
774
|
<Text>{confirmQuestion(confirmStopId)} y Yes / n No</Text>
|
|
@@ -751,6 +835,7 @@ export async function renderSupervisedSessionView(
|
|
|
751
835
|
loadRows={options.loadRows}
|
|
752
836
|
onStop={options.onStop}
|
|
753
837
|
onStart={options.onStart}
|
|
838
|
+
startTrustQuestion={options.startTrustQuestion}
|
|
754
839
|
onOpenPr={options.onOpenPr}
|
|
755
840
|
onAttach={(request) => {
|
|
756
841
|
attach = request;
|
|
@@ -100,13 +100,21 @@ export class TuiInteractionChannel implements ITuiAppChannelPort {
|
|
|
100
100
|
session: this.interactiveSession,
|
|
101
101
|
manager: this.stateManager,
|
|
102
102
|
...(this.attention ? { attention: this.attention } : {}),
|
|
103
|
-
requestPermission: (
|
|
103
|
+
requestPermission: (
|
|
104
|
+
toolName,
|
|
105
|
+
toolArgs,
|
|
106
|
+
id,
|
|
107
|
+
canPersistProjectPermission,
|
|
108
|
+
requestedByPeer,
|
|
109
|
+
requester,
|
|
110
|
+
) =>
|
|
104
111
|
this.permissions.enqueue(
|
|
105
112
|
toolName,
|
|
106
113
|
toolArgs,
|
|
107
114
|
id,
|
|
108
115
|
canPersistProjectPermission,
|
|
109
116
|
requestedByPeer,
|
|
117
|
+
requester,
|
|
110
118
|
),
|
|
111
119
|
askUser: (request, id) => this.askUser(request, id),
|
|
112
120
|
dismissPrompt: (id) => this.dismissPromptById(id),
|
|
@@ -26,6 +26,11 @@ const THIRD: ISupervisedViewRow = {
|
|
|
26
26
|
generation: 'thirdGenerationValue01',
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
const QUESTION = {
|
|
30
|
+
folder: '/work/repo',
|
|
31
|
+
loads: ['Project sources (metadata only; content not read):', ' [file] .robota/settings.json — settings'],
|
|
32
|
+
};
|
|
33
|
+
|
|
29
34
|
describe('supervised session view', () => {
|
|
30
35
|
it('shows a linked PR only while verified and opens it only on explicit keypress', async () => {
|
|
31
36
|
const url = 'https://github.com/team/repo/pull/123';
|
|
@@ -383,6 +388,109 @@ describe('supervised session view', () => {
|
|
|
383
388
|
}
|
|
384
389
|
});
|
|
385
390
|
|
|
391
|
+
it('asks about a folder not trusted yet, and starts as the answer says (#3268)', async () => {
|
|
392
|
+
const start = vi.fn(async () => SECOND.id);
|
|
393
|
+
const view = render(
|
|
394
|
+
<SupervisedSessionView
|
|
395
|
+
loadRows={async () => [FIRST]}
|
|
396
|
+
onStart={start}
|
|
397
|
+
startTrustQuestion={async () => QUESTION}
|
|
398
|
+
/>,
|
|
399
|
+
);
|
|
400
|
+
try {
|
|
401
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
402
|
+
view.stdin.write('n');
|
|
403
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
404
|
+
// It names the folder and what trusting it would load.
|
|
405
|
+
expect(view.lastFrame()).toContain('Not trusted: /work/repo');
|
|
406
|
+
expect(view.lastFrame()).toContain('.robota/settings.json');
|
|
407
|
+
expect(start).not.toHaveBeenCalled();
|
|
408
|
+
// n cancels: nothing starts.
|
|
409
|
+
view.stdin.write('n');
|
|
410
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
|
|
411
|
+
expect(start).not.toHaveBeenCalled();
|
|
412
|
+
view.stdin.write('n');
|
|
413
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
414
|
+
view.stdin.write('r');
|
|
415
|
+
await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('restricted'));
|
|
416
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
|
|
417
|
+
view.stdin.write('n');
|
|
418
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
419
|
+
view.stdin.write('y');
|
|
420
|
+
await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('trust'));
|
|
421
|
+
} finally {
|
|
422
|
+
view.unmount();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('fits a long trust question in a 24-row terminal, the folder and the choices whole', async () => {
|
|
427
|
+
const loads = Array.from({ length: 32 }, (_, index) => ` [absent] source-${index} — a source`);
|
|
428
|
+
const view = render(
|
|
429
|
+
<SupervisedSessionView
|
|
430
|
+
loadRows={async () => [FIRST]}
|
|
431
|
+
onStart={async () => SECOND.id}
|
|
432
|
+
startTrustQuestion={async () => ({ folder: '/work/repo', loads })}
|
|
433
|
+
/>,
|
|
434
|
+
);
|
|
435
|
+
try {
|
|
436
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
437
|
+
view.stdin.write('n');
|
|
438
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
439
|
+
const frame = view.lastFrame() ?? '';
|
|
440
|
+
const lines = frame.split('\n');
|
|
441
|
+
expect(lines.length).toBeLessThanOrEqual(24);
|
|
442
|
+
expect(lines).toContain('Not trusted: /work/repo');
|
|
443
|
+
expect(lines).toContain('y Trust and start / r Start Restricted / n Cancel');
|
|
444
|
+
expect(frame).toMatch(/… \d+ more — see robota trust status/);
|
|
445
|
+
expect(frame).toContain('source-0 — a source');
|
|
446
|
+
} finally {
|
|
447
|
+
view.unmount();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('cancels the trust question on Escape in screen-reader mode, and keeps the view open', async () => {
|
|
452
|
+
const start = vi.fn(async () => SECOND.id);
|
|
453
|
+
const view = render(
|
|
454
|
+
<ScreenReaderProvider enabled>
|
|
455
|
+
<SupervisedSessionView
|
|
456
|
+
loadRows={async () => [FIRST]}
|
|
457
|
+
onStart={start}
|
|
458
|
+
startTrustQuestion={async () => QUESTION}
|
|
459
|
+
/>
|
|
460
|
+
</ScreenReaderProvider>,
|
|
461
|
+
);
|
|
462
|
+
try {
|
|
463
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
464
|
+
view.stdin.write('n');
|
|
465
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
466
|
+
view.stdin.write('\u001b');
|
|
467
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
|
|
468
|
+
expect(view.lastFrame()).toContain(FIRST.id);
|
|
469
|
+
expect(start).not.toHaveBeenCalled();
|
|
470
|
+
} finally {
|
|
471
|
+
view.unmount();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('starts at once where the folder needs no answer', async () => {
|
|
476
|
+
const start = vi.fn(async () => SECOND.id);
|
|
477
|
+
const view = render(
|
|
478
|
+
<SupervisedSessionView
|
|
479
|
+
loadRows={async () => [FIRST]}
|
|
480
|
+
onStart={start}
|
|
481
|
+
startTrustQuestion={async () => undefined}
|
|
482
|
+
/>,
|
|
483
|
+
);
|
|
484
|
+
try {
|
|
485
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
486
|
+
view.stdin.write('n');
|
|
487
|
+
await vi.waitFor(() => expect(start).toHaveBeenCalledExactlyOnceWith(undefined));
|
|
488
|
+
expect(view.lastFrame()).not.toContain('r Start Restricted');
|
|
489
|
+
} finally {
|
|
490
|
+
view.unmount();
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
386
494
|
it('keeps stop cancellation separate from starting a session', async () => {
|
|
387
495
|
const start = vi.fn(async () => SECOND.id);
|
|
388
496
|
const view = render(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The terminal matrix, as data.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Checking IME cursor placement across terminals was a *manual* matrix ("iTerm2 + Terminal.app
|
|
5
5
|
* ±ROBOTA_IME_CURSOR, kitty/WezTerm/Ghostty/Windows Terminal/tmux on real hardware"). This module
|
|
6
6
|
* converts it into something a machine re-runs: each row is the environment handshake a terminal
|
|
7
7
|
* emulator hands the program it launches, which is the ONLY channel through which the emulator's
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
export type TerminalProvenance = 'measured' | 'documented';
|
|
23
23
|
|
|
24
24
|
export interface ITerminalProfile {
|
|
25
|
-
/** Stable id used
|
|
25
|
+
/** Stable id used to look a profile up. */
|
|
26
26
|
id: string;
|
|
27
27
|
/** Human label for the matrix. */
|
|
28
28
|
label: string;
|
|
@@ -144,7 +144,7 @@ export function profileEnv(
|
|
|
144
144
|
};
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/** Matrix label used in test names
|
|
147
|
+
/** Matrix label used in test names. */
|
|
148
148
|
export function cellLabel(profile: ITerminalProfile, override: string | undefined): string {
|
|
149
149
|
const setting =
|
|
150
150
|
override === undefined ? 'ROBOTA_IME_CURSOR unset' : `ROBOTA_IME_CURSOR=${override}`;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import PermissionPrompt from '../PermissionPrompt.js';
|
|
6
|
+
|
|
7
|
+
describe('issue #3288 §1: a background agent names itself on its own permission request', () => {
|
|
8
|
+
it('says which background agent is asking, instead of an unattributed prompt', () => {
|
|
9
|
+
const frame = render(
|
|
10
|
+
<PermissionPrompt
|
|
11
|
+
request={{
|
|
12
|
+
toolName: 'Glob',
|
|
13
|
+
toolArgs: { pattern: '**/*' },
|
|
14
|
+
requester: { kind: 'background-agent', label: 'general-purpose', taskId: 'agent_1' },
|
|
15
|
+
resolve: () => {},
|
|
16
|
+
}}
|
|
17
|
+
/>,
|
|
18
|
+
).lastFrame();
|
|
19
|
+
|
|
20
|
+
expect(frame).toContain('Background agent');
|
|
21
|
+
expect(frame).toContain('general-purpose');
|
|
22
|
+
expect(frame).toContain('Glob');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reads as an ordinary ask when no requester is present', () => {
|
|
26
|
+
const frame = render(
|
|
27
|
+
<PermissionPrompt
|
|
28
|
+
request={{ toolName: 'Bash', toolArgs: { command: 'ls' }, resolve: () => {} }}
|
|
29
|
+
/>,
|
|
30
|
+
).lastFrame();
|
|
31
|
+
|
|
32
|
+
expect(frame).not.toContain('Background agent');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -224,6 +224,14 @@ async function expectCommitOnYes(session: IPtySession): Promise<void> {
|
|
|
224
224
|
await session.waitForSince(since, /Committed: \[main [0-9a-f]{7}\] feat: add greeting/, WAIT_MS);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** The fixture repository is not trusted, so the TUI asks first (issue #3268); these scenarios run Restricted. */
|
|
228
|
+
async function startRestricted(session: IPtySession): Promise<void> {
|
|
229
|
+
await session.waitFor(/Trust this folder\? \[y\/N\]/, WAIT_MS);
|
|
230
|
+
await session.sendKeys('n');
|
|
231
|
+
await session.pressEnter();
|
|
232
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
227
235
|
async function exitTui(session: IPtySession): Promise<number> {
|
|
228
236
|
await run(session, '/exit');
|
|
229
237
|
await session.waitFor(/Exit the session\?/, WAIT_MS);
|
|
@@ -251,7 +259,7 @@ describe('/git through the real binary (BEHAVIOR-2437 TC-07)', () => {
|
|
|
251
259
|
async () => {
|
|
252
260
|
fixture = makeFixture(true);
|
|
253
261
|
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
|
|
254
|
-
await session
|
|
262
|
+
await startRestricted(session);
|
|
255
263
|
await session.waitFor(/Idle/, WAIT_MS);
|
|
256
264
|
|
|
257
265
|
await expectReadSteps(session);
|
|
@@ -272,7 +280,7 @@ describe('/git through the real binary (BEHAVIOR-2437 TC-07)', () => {
|
|
|
272
280
|
async () => {
|
|
273
281
|
fixture = makeFixture(true);
|
|
274
282
|
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
|
|
275
|
-
await session
|
|
283
|
+
await startRestricted(session);
|
|
276
284
|
await session.waitFor(/Idle/, WAIT_MS);
|
|
277
285
|
|
|
278
286
|
await expectSubjectRefusal(session, fixture);
|
|
@@ -296,7 +304,7 @@ describe('/git through the real binary (BEHAVIOR-2437 TC-07)', () => {
|
|
|
296
304
|
async () => {
|
|
297
305
|
fixture = makeFixture(false);
|
|
298
306
|
session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
|
|
299
|
-
await session
|
|
307
|
+
await startRestricted(session);
|
|
300
308
|
await session.waitFor(/Idle/, WAIT_MS);
|
|
301
309
|
|
|
302
310
|
const since = await run(session, '/git commit feat: nothing to commit');
|