@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.
- package/CHANGELOG.md +858 -0
- package/README.md +5 -1
- package/dist/node/index.d.ts +189 -54
- 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 +16 -21
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +50 -17
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +479 -157
- package/src/TuiInteractionChannel.ts +8 -53
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +453 -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__/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__/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__/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__/wire-tui-channel.test.ts +1445 -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 +17 -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-session-event-projector.ts +0 -2
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +17 -3
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +962 -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
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
|
-
/**
|
|
108
|
-
|
|
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.
|
|
289
|
-
? {
|
|
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(
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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,
|
package/src/tui-state-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|