@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-terminal",
3
- "version": "3.0.0-beta.83",
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.83",
50
- "@robota-sdk/agent-framework": "3.0.0-beta.83",
51
- "@robota-sdk/agent-interface-analytics": "3.0.0-beta.83",
52
- "@robota-sdk/agent-interface-command": "3.0.0-beta.83",
53
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.83",
54
- "@robota-sdk/agent-interface-session": "3.0.0-beta.83",
55
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.83",
56
- "@robota-sdk/agent-transport": "3.0.0-beta.83",
57
- "@robota-sdk/agent-interface-tui": "3.0.0-beta.83"
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.83"
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": {
@@ -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={`${request.toolName} — ${formatArgs(request.toolArgs)}${
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
- <Text>
186
- Tool:{' '}
187
- <Text color={palette.text.accent} bold>
188
- {request.toolName}
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
- </Text>
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
- setStartStatus('starting');
436
- void Promise.resolve()
437
- .then(onStart)
438
- .then((id) => {
496
+ void startTrustQuestion()
497
+ .then((question) => {
498
+ startingRef.current = false;
439
499
  if (!mountedRef.current) return;
440
- setLastStartedId(id);
441
- setStartStatus('started');
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: screenReader && confirmStopId === undefined && stopStatus !== 'stopping',
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
- {start > 0 && !screenReader && <Text>{start} more above</Text>}
621
- {visible.map((line) =>
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
- {!screenReader && start + visible.length < displayLines.length && (
656
- <Text>{displayLines.length - start - visible.length} more below</Text>
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: (toolName, toolArgs, id, canPersistProjectPermission, requestedByPeer) =>
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
- * CLI-062 — the terminal matrix, as data.
2
+ * The terminal matrix, as data.
3
3
  *
4
- * The backlog item's remaining work was a *manual* matrix ("iTerm2 + Terminal.app
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 in test names and in the backlog matrix table. */
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 and in the backlog's evidence table. */
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.waitFor(/Type a message or \/help/, WAIT_MS);
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.waitFor(/Type a message or \/help/, WAIT_MS);
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.waitFor(/Type a message or \/help/, WAIT_MS);
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');