@robota-sdk/agent-ui-terminal 3.0.0-beta.81 → 3.0.0-beta.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/README.md +5 -1
  3. package/dist/node/index.d.ts +189 -54
  4. package/dist/node/index.d.ts.map +1 -1
  5. package/dist/node/index.js +21 -21
  6. package/dist/node/index.js.map +1 -1
  7. package/package.json +16 -21
  8. package/src/App.tsx +14 -3
  9. package/src/AppPresentation.tsx +1 -1
  10. package/src/PermissionPrompt.tsx +50 -17
  11. package/src/SessionPicker.tsx +27 -7
  12. package/src/SessionStatusBar.tsx +6 -2
  13. package/src/StatusBar.tsx +24 -0
  14. package/src/SupervisedSessionView.tsx +479 -157
  15. package/src/TuiInteractionChannel.ts +8 -53
  16. package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
  17. package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
  18. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
  19. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
  20. package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
  21. package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
  22. package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
  23. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
  24. package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
  25. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  26. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  27. package/src/__tests__/palette-consistency.test.ts +0 -1
  28. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  29. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
  30. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
  31. package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
  32. package/src/__tests__/render-channel-options.test.ts +6 -6
  33. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  34. package/src/__tests__/screen-reader-menus.test.tsx +1 -1
  35. package/src/__tests__/session-picker.test.tsx +93 -0
  36. package/src/__tests__/status-bar.test.tsx +8 -0
  37. package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
  38. package/src/__tests__/wire-tui-channel.test.ts +1445 -0
  39. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  40. package/src/app-view-model.ts +6 -2
  41. package/src/attached-session-connection.ts +14 -0
  42. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  43. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
  44. package/src/hooks/useAppController.ts +6 -2
  45. package/src/hooks/useAppInputBindings.ts +20 -2
  46. package/src/hooks/useAppInteractionState.ts +3 -0
  47. package/src/hooks/useAppOverlays.ts +20 -4
  48. package/src/hooks/useNumberedSelection.ts +19 -2
  49. package/src/hooks/useTuiChannel.ts +13 -1
  50. package/src/index.ts +17 -3
  51. package/src/render.tsx +218 -27
  52. package/src/short-session-id.ts +9 -0
  53. package/src/slash-command-input.ts +11 -0
  54. package/src/tui-app-channel-port.ts +29 -1
  55. package/src/tui-channel-options.ts +11 -2
  56. package/src/tui-session-event-projector.ts +0 -2
  57. package/src/tui-session-events.ts +6 -0
  58. package/src/tui-session-options.ts +9 -0
  59. package/src/tui-state-manager.ts +17 -3
  60. package/src/waiting-loop-stop-notice.ts +23 -0
  61. package/src/wire-history-sync.ts +159 -0
  62. package/src/wire-tui-channel.ts +962 -0
  63. package/src/wire-tui-client-commands.ts +142 -0
  64. package/src/wire-tui-projection.ts +136 -0
  65. package/dist/node/index.cjs +0 -32
  66. package/dist/node/index.d.cts +0 -1066
  67. package/dist/node/index.d.cts.map +0 -1
package/src/render.tsx CHANGED
@@ -23,13 +23,20 @@ import type { ITerminalCapabilityOverrides } from './terminal-capabilities-conte
23
23
  import { createFocusReportingWriter } from './terminal-focus-reporting.js';
24
24
  import { TerminalHandoffController } from './terminal-handoff-controller.js';
25
25
  import { TuiInteractionChannel } from './TuiInteractionChannel.js';
26
+ import { WireTuiChannel } from './wire-tui-channel.js';
26
27
 
28
+ import type {
29
+ IAttachedSessionConnection,
30
+ TAttachedSessionEnd,
31
+ } from './attached-session-connection.js';
32
+ import type { IAttentionSource } from './attention/attention-tracker.js';
27
33
  import type { IKeybindingsSource } from './keybindings/node-keybindings-source.js';
28
34
  import type { TScreenReaderChannel } from './screen-reader-announcement.js';
29
35
  import type { IThemeRegistry } from './theme/theme-registry.js';
30
36
  import type { ITuiAppChannelPort } from './tui-app-channel-port.js';
31
37
  import type { ITuiCliAdapter } from './tui-cli-adapter.js';
32
38
  import type { ITuiInteractionChannelOptions } from './TuiInteractionChannel.js';
39
+ import type { ITuiClientCommands } from './wire-tui-client-commands.js';
33
40
  import type {
34
41
  IAIProvider,
35
42
  IToolWithEventService,
@@ -52,6 +59,7 @@ import type {
52
59
  IPerTurnRecallConfig,
53
60
  TWorkspaceProjectAccess,
54
61
  EditCheckpointStore,
62
+ TInteractiveSessionOptions,
55
63
  IOrgPolicy,
56
64
  IProviderErrorGuidance,
57
65
  IProjectSettingsPath,
@@ -104,8 +112,19 @@ export interface IRenderOptions {
104
112
  * is what disabled the one check that would have caught the missing declaration.
105
113
  */
106
114
  orgPolicy?: IOrgPolicy | undefined;
107
- /** Explicit authority- and permission-backed edit checkpoint capability. */
108
- editCheckpointStore?: EditCheckpointStore;
115
+ /**
116
+ * Builds the authority- and permission-backed edit checkpoint store for one session; absent, no
117
+ * session keeps checkpoints. A factory, not a store: a session switch builds the next channel
118
+ * while the old session may still be finishing a turn, and a store holds one turn in progress.
119
+ */
120
+ createEditCheckpointStore?: () => EditCheckpointStore;
121
+ /** The host's way to build each external-event grant's verifier; absent, no grant opens. */
122
+ externalEventVerifierFactory?: TInteractiveSessionOptions['externalEventVerifierFactory'];
123
+ /**
124
+ * #3189: the run's grant history, handed to every session this TUI builds so a switch replays no
125
+ * spent token and resets no rate. DECLARED for the reason `orgPolicy` above is.
126
+ */
127
+ externalEventGrantHistory?: TInteractiveSessionOptions['externalEventGrantHistory'];
109
128
  providerOverride?: string | undefined;
110
129
  /**
111
130
  * #1844: forwarded to the session so `/provider switch` can construct the provider it switches TO.
@@ -165,6 +184,8 @@ export interface IRenderOptions {
165
184
  */
166
185
  additionalTools?: IToolWithEventService[];
167
186
  defaultTools?: readonly IToolWithEventService[];
187
+ /** The sandbox the shell tools run under, so the session can let a confined command skip the prompt. */
188
+ sandboxClient?: ICreateSessionOptions['sandboxClient'];
168
189
  commandModules?: readonly ICommandModule[];
169
190
  commandHostAdapters?: ICommandHostAdapters;
170
191
  shellExec?: TShellExecFn;
@@ -285,8 +306,11 @@ export function toChannelOptions(
285
306
  ? { userSettingsSources: options.userSettingsSources }
286
307
  : {}),
287
308
  ...(options.orgPolicy !== undefined ? { orgPolicy: options.orgPolicy } : {}),
288
- ...(options.editCheckpointStore !== undefined
289
- ? { editCheckpointStore: options.editCheckpointStore }
309
+ ...(options.externalEventVerifierFactory !== undefined
310
+ ? { externalEventVerifierFactory: options.externalEventVerifierFactory }
311
+ : {}),
312
+ ...(options.externalEventGrantHistory !== undefined
313
+ ? { externalEventGrantHistory: options.externalEventGrantHistory }
290
314
  : {}),
291
315
  ...(options.providerDefinitions ? { providerDefinitions: options.providerDefinitions } : {}),
292
316
  // CLI-076: the display model id doubles as the session's model override so `--model` actually reaches
@@ -327,6 +351,7 @@ export function toChannelOptions(
327
351
  : {}),
328
352
  ...(options.additionalTools !== undefined ? { additionalTools: options.additionalTools } : {}),
329
353
  ...(options.defaultTools !== undefined ? { defaultTools: options.defaultTools } : {}),
354
+ ...(options.sandboxClient !== undefined ? { sandboxClient: options.sandboxClient } : {}),
330
355
  commandModules: options.commandModules,
331
356
  commandHostAdapters: options.commandHostAdapters,
332
357
  shellExec: options.shellExec,
@@ -380,21 +405,195 @@ export async function renderApp(options: IRenderOptions): Promise<void> {
380
405
  // is the product assembly's boundary (agent-cli installs the guards via onChannelReady).
381
406
  try {
382
407
  await options.keybindingsSource?.start();
383
- await renderStartedApp(options);
408
+ await renderStartedApp(options, (services) => ({
409
+ createChannel: createInProcessChannelFactory(options, services),
410
+ }));
411
+ } finally {
412
+ options.keybindingsSource?.dispose();
413
+ }
414
+ }
415
+
416
+ /**
417
+ * What the full TUI needs to attach to a session a host runs (a daemon) instead of building one.
418
+ * Only presentation options: everything else belongs to the host's session.
419
+ */
420
+ export interface IRenderAttachedAppOptions extends Pick<
421
+ IRenderOptions,
422
+ | 'cwd'
423
+ | 'productDisplayName'
424
+ | 'version'
425
+ | 'cliAdapter'
426
+ | 'screenReader'
427
+ | 'screenReaderChannel'
428
+ | 'screenReaderHint'
429
+ | 'screenReaderPacing'
430
+ | 'terminalCapabilities'
431
+ | 'keybindingsSource'
432
+ | 'themeRegistry'
433
+ | 'reducedMotion'
434
+ | 'reducedMotionOverride'
435
+ | 'focusReporting'
436
+ | 'promptHistorySource'
437
+ | 'promptHistoryProject'
438
+ > {
439
+ /** The session protocol connection; closing it belongs to the caller. */
440
+ readonly connection: IAttachedSessionConnection;
441
+ /** Shown as the session's label until the session reports its own name. */
442
+ readonly sessionLabel: string;
443
+ /** This terminal's driver id, from the attach handshake. */
444
+ readonly driverId: string;
445
+ /** Commands this terminal runs itself (`/shell`, `/theme`, ...); the host never sees them. */
446
+ readonly clientCommands?: ITuiClientCommands;
447
+ /**
448
+ * How this terminal is on the session: `'drive'` (the default) sends prompts and answers questions;
449
+ * `'observe'` only watches, and the host refuses anything else from it.
450
+ */
451
+ readonly mode?: 'drive' | 'observe';
452
+ /** Print the screen-reader line; false when this process already printed it. Default true. */
453
+ readonly announce?: boolean;
454
+ }
455
+
456
+ /**
457
+ * Render the full TUI on a session the terminal is attached to over the session protocol. Leaving
458
+ * only detaches: the session keeps running. Resolves `'user'` when the user leaves (`/exit`,
459
+ * Ctrl-C) and `'closed'` when the connection closed.
460
+ */
461
+ export async function renderAttachedApp(
462
+ options: IRenderAttachedAppOptions,
463
+ ): Promise<TAttachedSessionEnd> {
464
+ let end: TAttachedSessionEnd = 'user';
465
+ let endApp: () => void = () => undefined;
466
+ const ended = new Promise<void>((resolve) => {
467
+ endApp = resolve;
468
+ });
469
+ try {
470
+ await options.keybindingsSource?.start();
471
+ await renderStartedApp(options, (services) => {
472
+ let channel: WireTuiChannel | undefined;
473
+ return {
474
+ // One channel for the App's life: a session switch is the host's, and the channel follows it.
475
+ createChannel: () =>
476
+ (channel ??= new WireTuiChannel({
477
+ connection: options.connection,
478
+ driverId: options.driverId,
479
+ sessionName: options.sessionLabel,
480
+ role: options.mode ?? 'drive',
481
+ terminalHandoff: services.terminalHandoff,
482
+ attention: services.attention,
483
+ ...(options.clientCommands !== undefined
484
+ ? { clientCommands: options.clientCommands, cwd: options.cwd }
485
+ : { clientCommands: undefined }),
486
+ onEnd: (reason) => {
487
+ end = reason;
488
+ endApp();
489
+ },
490
+ })),
491
+ requestSessionSwitch: async (sessionId) => channel?.requestSessionSwitch(sessionId),
492
+ ended,
493
+ };
494
+ });
384
495
  } finally {
385
496
  options.keybindingsSource?.dispose();
386
497
  }
498
+ return end;
499
+ }
500
+
501
+ /** Presentation options the App shell reads, whichever kind of channel it renders. */
502
+ type TAppShellOptions = Pick<
503
+ IRenderOptions,
504
+ | 'cwd'
505
+ | 'productDisplayName'
506
+ | 'modelCommandToolPrefix'
507
+ | 'providerOverride'
508
+ | 'providerType'
509
+ | 'modelId'
510
+ | 'permissionMode'
511
+ | 'version'
512
+ | 'sessionStore'
513
+ | 'resumeSessionId'
514
+ | 'showSessionPickerOnStart'
515
+ | 'initialInput'
516
+ | 'initialInputOrigin'
517
+ | 'startupUpdateNotice'
518
+ | 'transportRegistry'
519
+ | 'commandHostAdapters'
520
+ | 'cliAdapter'
521
+ | 'promptHistorySource'
522
+ | 'promptHistoryProject'
523
+ | 'themeRegistry'
524
+ | 'reducedMotion'
525
+ | 'reducedMotionOverride'
526
+ | 'screenReader'
527
+ | 'screenReaderPacing'
528
+ | 'terminalCapabilities'
529
+ | 'screenReaderChannel'
530
+ | 'screenReaderHint'
531
+ | 'keybindingsSource'
532
+ | 'focusReporting'
533
+ > & {
534
+ /** Print the screen-reader line. Default true; false when this process already printed it. */
535
+ readonly announce?: boolean;
536
+ };
537
+
538
+ /** Terminal-wide services a channel is built with; they outlive every channel. */
539
+ export interface IChannelServices {
540
+ readonly terminalHandoff: TerminalHandoffController;
541
+ readonly attention: IAttentionSource;
542
+ }
543
+
544
+ /** Where the App's channels come from, and how a session switch and an app end are decided. */
545
+ interface IAppChannelComposition {
546
+ readonly createChannel: (resumeSessionId?: string) => ITuiAppChannelPort;
547
+ /** Present when the host switches sessions and the channel follows it. */
548
+ readonly requestSessionSwitch?: (sessionId: string) => Promise<void>;
549
+ /** Settles when the app ends without the user closing it (the connection closed). */
550
+ readonly ended?: Promise<void>;
551
+ }
552
+
553
+ /**
554
+ * The in-process factory: every channel builds its own session, and its own checkpoint store, from
555
+ * the render options. A resumed session's store restores its active branch from the persisted pointer.
556
+ */
557
+ export function createInProcessChannelFactory(
558
+ options: IRenderOptions,
559
+ services: IChannelServices,
560
+ ): (resumeSessionId?: string) => ITuiAppChannelPort {
561
+ // Issue #3081: the move is announced by the FIRST session only — a later switch to another
562
+ // session (a `/fork` attach) did not move anywhere.
563
+ let pendingWorkspaceMovedFrom = options.workspaceMovedFrom;
564
+ return (resumeSessionId) => {
565
+ const workspaceMovedFrom = pendingWorkspaceMovedFrom;
566
+ pendingWorkspaceMovedFrom = undefined;
567
+ const channel = new TuiInteractionChannel({
568
+ ...toChannelOptions(options, resumeSessionId),
569
+ ...(options.createEditCheckpointStore !== undefined
570
+ ? { editCheckpointStore: options.createEditCheckpointStore() }
571
+ : {}),
572
+ ...(workspaceMovedFrom !== undefined ? { workspaceMovedFrom } : {}),
573
+ terminalHandoff: services.terminalHandoff,
574
+ attention: services.attention,
575
+ });
576
+ // Expose each live channel (incl. session-switch re-creations) to the embedding product,
577
+ // e.g. for process-level error routing (ERR-001 G1).
578
+ options.onChannelReady?.(channel);
579
+ return channel;
580
+ };
387
581
  }
388
582
 
389
- async function renderStartedApp(options: IRenderOptions): Promise<void> {
583
+ async function renderStartedApp(
584
+ options: TAppShellOptions,
585
+ compose: (services: IChannelServices) => IAppChannelComposition,
586
+ ): Promise<void> {
390
587
  // CLI-2004: one resolved boolean drives Ink's own screen-reader support AND the React context
391
588
  // every component reads. Both are set here so they can never disagree.
392
589
  const screenReader = options.screenReader === true;
393
- writeScreenReaderAnnouncement({
394
- enabled: screenReader,
395
- channel: options.screenReaderChannel,
396
- hint: options.screenReaderHint,
397
- });
590
+ if (options.announce !== false) {
591
+ writeScreenReaderAnnouncement({
592
+ enabled: screenReader,
593
+ channel: options.screenReaderChannel,
594
+ hint: options.screenReaderHint,
595
+ });
596
+ }
398
597
  const pacing = resolvePacing({ enabled: screenReader, overrides: options.screenReaderPacing });
399
598
  // SCREEN-2670: the pre-write park. Constructed only when the mode is on AND the interval is
400
599
  // non-zero, so with the mode off `stdout` is not passed at all and Ink defaults to
@@ -436,24 +635,11 @@ async function renderStartedApp(options: IRenderOptions): Promise<void> {
436
635
 
437
636
  // Concrete framework creation has one composition boundary. React receives only the bounded port;
438
637
  // App owns which narrowed channel is active, while each channel owns its own lifecycle.
638
+ const composition = compose({ terminalHandoff: handoffController, attention });
439
639
  let activeChannel: ITuiAppChannelPort | undefined;
440
- // Issue #3081: the move is announced by the FIRST session only — a later switch to another
441
- // session (a `/fork` attach) did not move anywhere.
442
- let pendingWorkspaceMovedFrom = options.workspaceMovedFrom;
443
640
  const createChannel = (resumeSessionId?: string): ITuiAppChannelPort => {
444
- const workspaceMovedFrom = pendingWorkspaceMovedFrom;
445
- pendingWorkspaceMovedFrom = undefined;
446
- const channel = new TuiInteractionChannel({
447
- ...toChannelOptions(options, resumeSessionId),
448
- ...(workspaceMovedFrom !== undefined ? { workspaceMovedFrom } : {}),
449
- terminalHandoff: handoffController,
450
- attention,
451
- });
452
- // Expose each live channel (incl. session-switch re-creations) to the embedding product,
453
- // e.g. for process-level error routing (ERR-001 G1).
454
- options.onChannelReady?.(channel);
455
- activeChannel = channel;
456
- return channel;
641
+ activeChannel = composition.createChannel(resumeSessionId);
642
+ return activeChannel;
457
643
  };
458
644
 
459
645
  // The startup quiet period sits between the confirmation line and the first frame, so a reader
@@ -486,6 +672,9 @@ async function renderStartedApp(options: IRenderOptions): Promise<void> {
486
672
  <App
487
673
  cwd={options.cwd}
488
674
  createChannel={createChannel}
675
+ {...(composition.requestSessionSwitch !== undefined
676
+ ? { requestSessionSwitch: composition.requestSessionSwitch }
677
+ : {})}
489
678
  providerOverride={options.providerOverride}
490
679
  providerType={options.providerType}
491
680
  modelId={options.modelId}
@@ -526,6 +715,8 @@ async function renderStartedApp(options: IRenderOptions): Promise<void> {
526
715
  );
527
716
  // The controller needs the Ink instance to clear the frame before a handoff.
528
717
  handoffController.setInkInstance(instance);
718
+ // Unmounting resolves `waitUntilExit`, so an app the host ended leaves the way a user exit does.
719
+ void composition.ended?.then(() => instance.unmount());
529
720
  try {
530
721
  await waitForRenderAndStop(
531
722
  () => instance.waitUntilExit(),
@@ -0,0 +1,9 @@
1
+ /** Every stored session id starts with this, so it tells no two sessions apart. */
2
+ const SESSION_ID_PREFIX = 'session_';
3
+ const SHORT_SESSION_ID_LENGTH = 8;
4
+
5
+ /** A session id short enough for a picker row or a status bar: the start of the part that differs. */
6
+ export function shortSessionId(id: string): string {
7
+ const distinct = id.startsWith(SESSION_ID_PREFIX) ? id.slice(SESSION_ID_PREFIX.length) : id;
8
+ return distinct === '' ? id : distinct.slice(0, SHORT_SESSION_ID_LENGTH);
9
+ }
@@ -0,0 +1,11 @@
1
+ /** A typed `/name args` line, split the way every TUI channel dispatches it. */
2
+ export interface ISlashCommandInput {
3
+ /** Lower-cased, without the slash. */
4
+ readonly name: string;
5
+ readonly args: string;
6
+ }
7
+
8
+ export function parseSlashCommandInput(input: string): ISlashCommandInput {
9
+ const parts = input.slice(1).split(/\s+/);
10
+ return { name: parts[0]?.toLowerCase() ?? '', args: parts.slice(1).join(' ') };
11
+ }
@@ -14,7 +14,11 @@ import type {
14
14
  IExecutionDetailPage,
15
15
  IExecutionWorkspaceSnapshot,
16
16
  } from '@robota-sdk/agent-interface-execution';
17
- import type { IInteractiveSessionEvents, IToolState } from '@robota-sdk/agent-interface-session';
17
+ import type {
18
+ IInteractiveSessionEvents,
19
+ ISessionListingEntry,
20
+ IToolState,
21
+ } from '@robota-sdk/agent-interface-session';
18
22
 
19
23
  export interface ITuiCommandQueryPort {
20
24
  getCommands(filter?: string): ICommand[];
@@ -33,6 +37,8 @@ export interface ITuiRuntimeStatusSnapshot {
33
37
  sessionId: string;
34
38
  activePresetId?: string;
35
39
  effort?: TModelEffortSelection;
40
+ /** The model the session reports, when its host says; absent ⇒ the one this terminal was started with. */
41
+ modelId?: string;
36
42
  }
37
43
 
38
44
  export interface ITuiChannelSnapshot {
@@ -52,6 +58,28 @@ export interface ITuiChannelSnapshot {
52
58
  permissionRequest: IPendingPermissionRequest | null;
53
59
  pendingUserAction: IActionRequest | null;
54
60
  contextState: { percentage: number; usedTokens: number; maxTokens: number };
61
+ /**
62
+ * The sessions the session picker offers, when the host keeps them (a terminal attached to a
63
+ * daemon). Absent ⇒ the picker lists this terminal's own session store. A host that keeps sessions
64
+ * live says which rows run now and how many clients are on each; the picker shows both.
65
+ */
66
+ hostSessions?: readonly ISessionListingEntry[];
67
+ /**
68
+ * Changes when the transcript starts over for another session while the App keeps this channel.
69
+ * The terminal prints the transcript once and counts what it printed, so a new transcript is
70
+ * printed from its start. Absent ⇒ the transcript never starts over under this channel.
71
+ */
72
+ transcriptGeneration?: number;
73
+ /**
74
+ * True while this terminal only observes the session: it sends nothing that changes it, and the
75
+ * App offers no key that would. Absent ⇒ the terminal drives the session.
76
+ */
77
+ readOnly?: boolean;
78
+ /**
79
+ * True when this terminal is attached to a session a host runs: leaving only detaches it, and
80
+ * Ctrl-] leaves too. Absent ⇒ the terminal runs its own session, where Ctrl-] means nothing.
81
+ */
82
+ attached?: boolean;
55
83
  }
56
84
 
57
85
  /**
@@ -20,6 +20,7 @@ import type {
20
20
  ICreateSessionOptions,
21
21
  ILivePromptTracePort,
22
22
  EditCheckpointStore,
23
+ TInteractiveSessionOptions,
23
24
  IMemoryStore,
24
25
  IPromptHistoryOptions,
25
26
  IPerTurnRecallConfig,
@@ -79,8 +80,15 @@ export interface ITuiInteractionChannelOptions {
79
80
  contributionSources?: ICreateSessionOptions['contributionSources'];
80
81
  skillRoots?: ICreateSessionOptions['skillRoots'];
81
82
  userSettingsSources?: readonly INodeHostSettingsSource[];
82
- /** Explicit authority- and permission-backed edit checkpoint capability. */
83
+ /**
84
+ * Explicit authority- and permission-backed edit checkpoint capability for this channel's one
85
+ * session. Never shared with another channel's session: a store holds one turn in progress.
86
+ */
83
87
  editCheckpointStore?: EditCheckpointStore;
88
+ /** The host's way to build each external-event grant's verifier; absent, no grant opens. */
89
+ externalEventVerifierFactory?: TInteractiveSessionOptions['externalEventVerifierFactory'];
90
+ /** The run's grant history, shared by every session this TUI binds (#3189). */
91
+ externalEventGrantHistory?: TInteractiveSessionOptions['externalEventGrantHistory'];
84
92
  /**
85
93
  * CLI-076: the resolved model id (the same value the status line displays). Forwarded to the session so an
86
94
  * explicit `--model` override reaches the provider chat call instead of being silently replaced by the
@@ -106,7 +114,6 @@ export interface ITuiInteractionChannelOptions {
106
114
  /** Issue #3081: this channel's session is the target of a `/cd` from this directory. */
107
115
  workspaceMovedFrom?: string;
108
116
  sessionName?: string;
109
- onAutoNamed?: (name: string) => void;
110
117
  backgroundTaskRunners?: IBackgroundTaskRunner[];
111
118
  /** MCP-004: forwarded to `ICreateSessionOptions.toolCallHandoff`. */
112
119
  toolCallHandoff?: IToolCallHandoffPolicy;
@@ -122,6 +129,8 @@ export interface ITuiInteractionChannelOptions {
122
129
  */
123
130
  additionalTools?: IToolWithEventService[];
124
131
  defaultTools?: readonly IToolWithEventService[];
132
+ /** The sandbox the shell tools run under, so the session can let a confined command skip the prompt. */
133
+ sandboxClient?: ICreateSessionOptions['sandboxClient'];
125
134
  commandModules?: readonly ICommandModule[];
126
135
  commandHostAdapters?: ICommandHostAdapters;
127
136
  shellExec?: TShellExecFn;
@@ -19,7 +19,6 @@ import type {
19
19
  export interface ITuiSessionEventProjectorOptions {
20
20
  session: InteractiveSession;
21
21
  manager: TuiStateManager;
22
- onUserMessage: (content: string) => void;
23
22
  requestPermission: (
24
23
  toolName: string,
25
24
  toolArgs: TToolArgs,
@@ -45,7 +44,6 @@ export class TuiSessionEventProjector {
45
44
  const { session, manager, attention } = this.options;
46
45
 
47
46
  const onUserMessage = (content: string): void => {
48
- this.options.onUserMessage(content);
49
47
  manager.addUserEcho(attributedUserEcho(content, session));
50
48
  };
51
49
  const syncHistory = (): void => manager.syncHistory(session.getFullHistory());
@@ -35,6 +35,12 @@ export const TUI_SESSION_EVENT_CLASSIFICATION = {
35
35
  ui_intent: 'react-side-effect',
36
36
  session_renamed: 'react-side-effect',
37
37
  history_cleared: 'channel',
38
+ // #3189: a host's session slot emits it on a switch; the TUI switches by remounting its own
39
+ // channel onto the next session, so it never follows a slot.
40
+ session_switched: 'non-surface',
41
+ // A host forwards it to attached clients as `session_status`; this channel owns its session and
42
+ // reads the status from it on each render.
43
+ status_changed: 'non-surface',
38
44
  } as const satisfies Record<TInteractiveEventName, TTuiSessionEventClassification>;
39
45
 
40
46
  export interface ITuiSessionEventNotice {
@@ -55,6 +55,12 @@ export function buildTuiSessionOptions(
55
55
  ...(opts.editCheckpointStore !== undefined
56
56
  ? { editCheckpointStore: opts.editCheckpointStore }
57
57
  : {}),
58
+ ...(opts.externalEventVerifierFactory !== undefined
59
+ ? { externalEventVerifierFactory: opts.externalEventVerifierFactory }
60
+ : {}),
61
+ ...(opts.externalEventGrantHistory !== undefined
62
+ ? { externalEventGrantHistory: opts.externalEventGrantHistory }
63
+ : {}),
58
64
  // CLI-076: forward the resolved model so `--model` takes effect rather than falling through to the
59
65
  // session's config/default model.
60
66
  ...(opts.model !== undefined ? { model: opts.model } : {}),
@@ -80,6 +86,8 @@ export function buildTuiSessionOptions(
80
86
  ? { workspaceMovedFrom: opts.workspaceMovedFrom }
81
87
  : {}),
82
88
  sessionName: opts.sessionName,
89
+ // The session names itself after its first real turn, unless it already has a name.
90
+ autoName: true,
83
91
  backgroundTaskRunners: opts.backgroundTaskRunners,
84
92
  ...(opts.toolCallHandoff !== undefined ? { toolCallHandoff: opts.toolCallHandoff } : {}),
85
93
  subagentRunnerFactory: opts.subagentRunnerFactory,
@@ -90,6 +98,7 @@ export function buildTuiSessionOptions(
90
98
  ...(opts.pluginDirectories !== undefined ? { pluginDirectories: opts.pluginDirectories } : {}),
91
99
  ...(opts.additionalTools !== undefined ? { additionalTools: opts.additionalTools } : {}),
92
100
  ...(opts.defaultTools !== undefined ? { defaultTools: opts.defaultTools } : {}),
101
+ ...(opts.sandboxClient !== undefined ? { sandboxClient: opts.sandboxClient } : {}),
93
102
  commandModules: opts.commandModules,
94
103
  commandHostAdapters: opts.commandHostAdapters,
95
104
  shellExec: opts.shellExec,
@@ -163,7 +163,7 @@ export class TuiStateManager {
163
163
  this.notify();
164
164
  };
165
165
 
166
- onComplete = (result: IExecutionResult): void => {
166
+ onComplete = (result: Pick<IExecutionResult, 'contextState'>): void => {
167
167
  this.clearStallTimer();
168
168
  this.debouncedStreamNotify.flush();
169
169
  this.streamBuf = '';
@@ -215,6 +215,21 @@ export class TuiStateManager {
215
215
  */
216
216
  syncHistory(entries: IHistoryEntry[]): void {
217
217
  if (entries.length === 0) return;
218
+ this.history = this.withLocalNotices(entries);
219
+ this.notify();
220
+ }
221
+
222
+ /**
223
+ * A host's history as a terminal attached to it last read it, then the echoes of prompts the host
224
+ * has not recorded yet. Unlike `syncHistory`, an empty history is an answer: the host's session
225
+ * has no entries.
226
+ */
227
+ syncHostHistory(entries: readonly IHistoryEntry[], echoes: readonly IHistoryEntry[]): void {
228
+ this.history = [...this.withLocalNotices(entries), ...echoes];
229
+ this.notify();
230
+ }
231
+
232
+ private withLocalNotices(entries: readonly IHistoryEntry[]): IHistoryEntry[] {
218
233
  // `<Static>` counts what it already printed, so a local notice dropped here would shift every
219
234
  // later index by one and the next committed entry (the assistant's answer) would never print.
220
235
  const merged: IHistoryEntry[] = [];
@@ -225,8 +240,7 @@ export class TuiStateManager {
225
240
  merged.push(notice.entry);
226
241
  }
227
242
  while (next < entries.length) merged.push(entries[next++]!);
228
- this.history = merged;
229
- this.notify();
243
+ return merged;
230
244
  }
231
245
 
232
246
  /** Add a notice this terminal owns; it survives the next session-history sync in place. */
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What Esc on an idle prompt says after asking the session to stop its waiting self-paced loop.
3
+ * The session decides which loop, if any, stops; both channels show its answer in the same words.
4
+ */
5
+
6
+ import type { TWaitingLoopStopOutcome } from '@robota-sdk/agent-interface-session';
7
+
8
+ /** The reason the session records for a loop Esc stopped. */
9
+ export const WAITING_LOOP_STOP_REASON = 'Loop stopped by Esc';
10
+
11
+ /** The notice for an outcome; none when no loop was waiting. */
12
+ export function waitingLoopStopNotice(outcome: TWaitingLoopStopOutcome): string | undefined {
13
+ switch (outcome.kind) {
14
+ case 'none':
15
+ return undefined;
16
+ case 'several':
17
+ return outcome.message;
18
+ case 'stopped':
19
+ return `Loop ${outcome.loopId} stopped by Esc.`;
20
+ case 'failed':
21
+ return outcome.message;
22
+ }
23
+ }