@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +893 -0
- package/README.md +34 -11
- package/dist/node/index.d.ts +205 -55
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +21 -21
- package/dist/node/index.js.map +1 -1
- package/package.json +19 -23
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +73 -23
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +568 -161
- package/src/TuiInteractionChannel.ts +17 -54
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +561 -82
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
- package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
- package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
- package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
- package/src/__tests__/helpers/terminal-profiles.ts +4 -4
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
- package/src/__tests__/palette-consistency.test.ts +0 -1
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +11 -3
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
- package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
- package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
- package/src/__tests__/render-channel-options.test.ts +6 -6
- package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
- package/src/__tests__/screen-reader-menus.test.tsx +1 -1
- package/src/__tests__/session-picker.test.tsx +93 -0
- package/src/__tests__/status-bar.test.tsx +8 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
- package/src/__tests__/tui-interaction-queues.test.ts +10 -0
- package/src/__tests__/tui-state-manager.test.ts +31 -0
- package/src/__tests__/wire-tui-channel.test.ts +1457 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-view-model.ts +6 -2
- package/src/attached-session-connection.ts +14 -0
- package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
- package/src/hooks/useAppController.ts +6 -2
- package/src/hooks/useAppInputBindings.ts +20 -2
- package/src/hooks/useAppInteractionState.ts +3 -0
- package/src/hooks/useAppOverlays.ts +20 -4
- package/src/hooks/useNumberedSelection.ts +19 -2
- package/src/hooks/useTuiChannel.ts +13 -1
- package/src/index.ts +19 -3
- package/src/render.tsx +218 -27
- package/src/short-session-id.ts +9 -0
- package/src/slash-command-input.ts +11 -0
- package/src/tui-app-channel-port.ts +29 -1
- package/src/tui-channel-options.ts +11 -2
- package/src/tui-interaction-queues.ts +9 -1
- package/src/tui-session-event-projector.ts +12 -3
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +25 -5
- package/src/types.ts +3 -1
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +963 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
- package/dist/node/index.cjs +0 -32
- package/dist/node/index.d.cts +0 -1066
- package/dist/node/index.d.cts.map +0 -1
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;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { IPendingPermissionRequest } from './types.js';
|
|
2
2
|
import type { IActionRequest, TActionResponse, TToolArgs } from '@robota-sdk/agent-core';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
TPermissionRequester,
|
|
5
|
+
TPermissionResultValue,
|
|
6
|
+
} from '@robota-sdk/agent-interface-session';
|
|
4
7
|
|
|
5
8
|
interface IUserActionQueueEntry {
|
|
6
9
|
presentedRequest: IActionRequest;
|
|
@@ -13,6 +16,8 @@ interface IPermissionQueueEntry {
|
|
|
13
16
|
toolArgs: TToolArgs;
|
|
14
17
|
canPersistProjectPermission?: boolean;
|
|
15
18
|
requestedByPeer?: string;
|
|
19
|
+
/** Issue #3288 §1: a background agent's own request, forwarded to the person. */
|
|
20
|
+
requester?: TPermissionRequester;
|
|
16
21
|
resolve: (result: TPermissionResultValue) => void;
|
|
17
22
|
id?: string;
|
|
18
23
|
}
|
|
@@ -96,6 +101,7 @@ export class TuiPermissionQueue {
|
|
|
96
101
|
id?: string,
|
|
97
102
|
canPersistProjectPermission?: boolean,
|
|
98
103
|
requestedByPeer?: string,
|
|
104
|
+
requester?: TPermissionRequester,
|
|
99
105
|
): Promise<TPermissionResultValue> {
|
|
100
106
|
return new Promise<TPermissionResultValue>((resolve) => {
|
|
101
107
|
this.entries.push({
|
|
@@ -105,6 +111,7 @@ export class TuiPermissionQueue {
|
|
|
105
111
|
...(id !== undefined ? { id } : {}),
|
|
106
112
|
...(canPersistProjectPermission !== undefined ? { canPersistProjectPermission } : {}),
|
|
107
113
|
...(requestedByPeer !== undefined ? { requestedByPeer } : {}),
|
|
114
|
+
...(requester !== undefined ? { requester } : {}),
|
|
108
115
|
});
|
|
109
116
|
this.processNext();
|
|
110
117
|
});
|
|
@@ -149,6 +156,7 @@ export class TuiPermissionQueue {
|
|
|
149
156
|
? { canPersistProjectPermission: next.canPersistProjectPermission }
|
|
150
157
|
: {}),
|
|
151
158
|
...(next.requestedByPeer !== undefined ? { requestedByPeer: next.requestedByPeer } : {}),
|
|
159
|
+
...(next.requester !== undefined ? { requester: next.requester } : {}),
|
|
152
160
|
resolve: (result) => {
|
|
153
161
|
if (this.entries[0] !== next) return;
|
|
154
162
|
this.entries.shift();
|
|
@@ -13,19 +13,21 @@ import type {
|
|
|
13
13
|
IExecutionResult,
|
|
14
14
|
IInteractiveSessionEvents,
|
|
15
15
|
TInteractiveEventName,
|
|
16
|
+
TPermissionRequester,
|
|
16
17
|
TPermissionResultValue,
|
|
17
18
|
} from '@robota-sdk/agent-interface-session';
|
|
18
19
|
|
|
19
20
|
export interface ITuiSessionEventProjectorOptions {
|
|
20
21
|
session: InteractiveSession;
|
|
21
22
|
manager: TuiStateManager;
|
|
22
|
-
onUserMessage: (content: string) => void;
|
|
23
23
|
requestPermission: (
|
|
24
24
|
toolName: string,
|
|
25
25
|
toolArgs: TToolArgs,
|
|
26
26
|
id: string,
|
|
27
27
|
canPersistProjectPermission?: boolean,
|
|
28
28
|
requestedByPeer?: string,
|
|
29
|
+
/** Issue #3288 §1: a background agent's own request, forwarded to the person. */
|
|
30
|
+
requester?: TPermissionRequester,
|
|
29
31
|
) => Promise<TPermissionResultValue>;
|
|
30
32
|
askUser: (request: IActionRequest, id: string) => Promise<TActionResponse>;
|
|
31
33
|
dismissPrompt: (id: string) => void;
|
|
@@ -45,7 +47,6 @@ export class TuiSessionEventProjector {
|
|
|
45
47
|
const { session, manager, attention } = this.options;
|
|
46
48
|
|
|
47
49
|
const onUserMessage = (content: string): void => {
|
|
48
|
-
this.options.onUserMessage(content);
|
|
49
50
|
manager.addUserEcho(attributedUserEcho(content, session));
|
|
50
51
|
};
|
|
51
52
|
const syncHistory = (): void => manager.syncHistory(session.getFullHistory());
|
|
@@ -88,6 +89,7 @@ export class TuiSessionEventProjector {
|
|
|
88
89
|
toolArgs,
|
|
89
90
|
canPersistProjectPermission,
|
|
90
91
|
requesterDriverId,
|
|
92
|
+
requester,
|
|
91
93
|
}) => {
|
|
92
94
|
attention?.onNeedsInput();
|
|
93
95
|
// A peer turn's ask names the peer, printed only as a plain identifier.
|
|
@@ -95,7 +97,14 @@ export class TuiSessionEventProjector {
|
|
|
95
97
|
? printablePeerDriver(requesterDriverId)
|
|
96
98
|
: undefined;
|
|
97
99
|
void this.options
|
|
98
|
-
.requestPermission(
|
|
100
|
+
.requestPermission(
|
|
101
|
+
toolName,
|
|
102
|
+
toolArgs,
|
|
103
|
+
id,
|
|
104
|
+
canPersistProjectPermission,
|
|
105
|
+
requestedByPeer,
|
|
106
|
+
requester,
|
|
107
|
+
)
|
|
99
108
|
.then((result) => session.resolvePermission(id, result))
|
|
100
109
|
.catch(() => session.resolvePermission(id, false));
|
|
101
110
|
};
|
|
@@ -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
|
@@ -130,8 +130,14 @@ export class TuiStateManager {
|
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
onToolEnd = (state: IToolState): void => {
|
|
133
|
-
//
|
|
134
|
-
|
|
133
|
+
// #3288: attribute by executionId first — two same-named parallel calls can finish out of
|
|
134
|
+
// start order, and `findLastIndex` by name alone would close the wrong (most recently
|
|
135
|
+
// started, not most recently finished) running entry. Fall back to the name+running-order
|
|
136
|
+
// heuristic only when the event carries no executionId (legacy fixtures / old hosts).
|
|
137
|
+
const idx =
|
|
138
|
+
state.executionId !== undefined
|
|
139
|
+
? this.activeTools.findIndex((t) => t.executionId === state.executionId && t.isRunning)
|
|
140
|
+
: this.activeTools.findLastIndex((t) => t.toolName === state.toolName && t.isRunning);
|
|
135
141
|
if (idx !== -1) {
|
|
136
142
|
const updated = [...this.activeTools];
|
|
137
143
|
updated[idx] = state;
|
|
@@ -163,7 +169,7 @@ export class TuiStateManager {
|
|
|
163
169
|
this.notify();
|
|
164
170
|
};
|
|
165
171
|
|
|
166
|
-
onComplete = (result: IExecutionResult): void => {
|
|
172
|
+
onComplete = (result: Pick<IExecutionResult, 'contextState'>): void => {
|
|
167
173
|
this.clearStallTimer();
|
|
168
174
|
this.debouncedStreamNotify.flush();
|
|
169
175
|
this.streamBuf = '';
|
|
@@ -215,6 +221,21 @@ export class TuiStateManager {
|
|
|
215
221
|
*/
|
|
216
222
|
syncHistory(entries: IHistoryEntry[]): void {
|
|
217
223
|
if (entries.length === 0) return;
|
|
224
|
+
this.history = this.withLocalNotices(entries);
|
|
225
|
+
this.notify();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A host's history as a terminal attached to it last read it, then the echoes of prompts the host
|
|
230
|
+
* has not recorded yet. Unlike `syncHistory`, an empty history is an answer: the host's session
|
|
231
|
+
* has no entries.
|
|
232
|
+
*/
|
|
233
|
+
syncHostHistory(entries: readonly IHistoryEntry[], echoes: readonly IHistoryEntry[]): void {
|
|
234
|
+
this.history = [...this.withLocalNotices(entries), ...echoes];
|
|
235
|
+
this.notify();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private withLocalNotices(entries: readonly IHistoryEntry[]): IHistoryEntry[] {
|
|
218
239
|
// `<Static>` counts what it already printed, so a local notice dropped here would shift every
|
|
219
240
|
// later index by one and the next committed entry (the assistant's answer) would never print.
|
|
220
241
|
const merged: IHistoryEntry[] = [];
|
|
@@ -225,8 +246,7 @@ export class TuiStateManager {
|
|
|
225
246
|
merged.push(notice.entry);
|
|
226
247
|
}
|
|
227
248
|
while (next < entries.length) merged.push(entries[next++]!);
|
|
228
|
-
|
|
229
|
-
this.notify();
|
|
249
|
+
return merged;
|
|
230
250
|
}
|
|
231
251
|
|
|
232
252
|
/** Add a notice this terminal owns; it survives the next session-history sync in place. */
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** UI-layer permission types for the Ink TUI */
|
|
2
2
|
|
|
3
3
|
import type { TToolArgs } from '@robota-sdk/agent-core';
|
|
4
|
-
import type { TPermissionResultValue } from '@robota-sdk/agent-interface-session';
|
|
4
|
+
import type { TPermissionRequester, TPermissionResultValue } from '@robota-sdk/agent-interface-session';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Permission result: true (allow once), false (deny), 'allow-session' (remember for session),
|
|
@@ -15,5 +15,7 @@ export interface IPendingPermissionRequest {
|
|
|
15
15
|
canPersistProjectPermission?: boolean;
|
|
16
16
|
/** The peer session whose message this call serves, when a peer turn asked. */
|
|
17
17
|
requestedByPeer?: string;
|
|
18
|
+
/** Issue #3288 §1: a background agent's own request, forwarded to the person. */
|
|
19
|
+
requester?: TPermissionRequester;
|
|
18
20
|
resolve: (result: TPermissionResult) => void;
|
|
19
21
|
}
|