@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 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
@@ -0,0 +1,962 @@
1
+ /**
2
+ * WireTuiChannel — the full TUI's channel for a session this terminal is attached to over the
3
+ * session protocol (a daemon's session).
4
+ *
5
+ * The in-process channel owns its session; this one owns only its view of one. Each frame is
6
+ * projected into the same render state the in-process event projector builds, and what the user does
7
+ * goes back as a client message. Leaving — stop, shutdown, `/exit`, a closed connection — only
8
+ * detaches: nothing sent from here ends the host's session, and a question left open stays open for
9
+ * the other clients.
10
+ *
11
+ * The commands that belong to this terminal (`/shell`, `/editor`, `/theme`, `/keybindings`) never
12
+ * reach the host: they run here, on this terminal and in its working directory.
13
+ *
14
+ * An observing terminal (`role: 'observe'`) sends only the reads an observer may send. Anything the
15
+ * user does that would change the session is refused here with a notice; `/exit` and this terminal's
16
+ * own commands still run.
17
+ */
18
+
19
+ import {
20
+ createSystemMessage,
21
+ messageToHistoryEntry,
22
+ printablePeerDriver,
23
+ } from '@robota-sdk/agent-core';
24
+ import { OWNER_DRIVER_ID } from '@robota-sdk/agent-interface-session';
25
+ import { isObserverMessageType } from '@robota-sdk/agent-transport/client';
26
+
27
+ import { AttentionCoordinator } from './attention/attention-coordinator.js';
28
+ import { attributedUserEcho } from './attributed-user-echo.js';
29
+ import { shortSessionId } from './short-session-id.js';
30
+ import { parseSlashCommandInput } from './slash-command-input.js';
31
+ import { TuiChannelLifecycleCoordinator } from './tui-channel-lifecycle-coordinator.js';
32
+ import { TuiPermissionQueue, TuiUserActionQueue } from './tui-interaction-queues.js';
33
+ import { TuiStateManager } from './tui-state-manager.js';
34
+ import { waitingLoopStopNotice } from './waiting-loop-stop-notice.js';
35
+ import { WireHistorySync } from './wire-history-sync.js';
36
+ import {
37
+ createTuiClientCommandHost,
38
+ findTuiClientCommand,
39
+ runTuiClientCommand,
40
+ } from './wire-tui-client-commands.js';
41
+ import {
42
+ displayDriverId,
43
+ filterCommandCatalog,
44
+ findSubcommands,
45
+ toCommandCatalog,
46
+ toHistoryEntries,
47
+ } from './wire-tui-projection.js';
48
+
49
+ import type {
50
+ IAttachedSessionConnection,
51
+ TAttachedSessionEnd,
52
+ } from './attached-session-connection.js';
53
+ import type { IAttentionSource } from './attention/attention-tracker.js';
54
+ import type { TerminalHandoffController } from './terminal-handoff-controller.js';
55
+ import type {
56
+ ITuiClientCommand,
57
+ ITuiClientCommands,
58
+ TTuiClientCommandHost,
59
+ } from './wire-tui-client-commands.js';
60
+ import type { IOwnDriver } from './wire-tui-projection.js';
61
+ import type {
62
+ ITuiAppChannelPort,
63
+ ITuiChannelSnapshot,
64
+ ITuiCommandQueryPort,
65
+ ITuiRuntimeStatusSnapshot,
66
+ ITuiSessionUiEventPort,
67
+ TTuiSessionUiEventName,
68
+ } from './tui-app-channel-port.js';
69
+ import type {
70
+ IActionRequest,
71
+ IHistoryEntry,
72
+ TActionResponse,
73
+ TPermissionMode,
74
+ TSessionEndReason,
75
+ } from '@robota-sdk/agent-core';
76
+ import type { ICommand } from '@robota-sdk/agent-interface-command';
77
+ import type { IExecutionDetailPage } from '@robota-sdk/agent-interface-execution';
78
+ import type {
79
+ IAskRequestEvent,
80
+ IInteractiveSessionEvents,
81
+ IPermissionRequestEvent,
82
+ ISessionListingEntry,
83
+ ISessionRenamedEvent,
84
+ ISessionStatusSnapshot,
85
+ IUiIntentEvent,
86
+ TWaitingLoopStopOutcome,
87
+ } from '@robota-sdk/agent-interface-session';
88
+ import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport/client';
89
+
90
+ interface IWireTuiChannelBaseOptions {
91
+ readonly connection: IAttachedSessionConnection;
92
+ /** The driver id the host gave this connection; the transcript shows its prompts as the user's. */
93
+ readonly driverId?: string;
94
+ readonly sessionName?: string;
95
+ readonly terminalHandoff?: TerminalHandoffController;
96
+ readonly attention?: IAttentionSource;
97
+ /** Told once, when this terminal stops showing the session: the user left or the connection closed. */
98
+ readonly onEnd?: (reason: TAttachedSessionEnd) => void;
99
+ /**
100
+ * How the host let this terminal on: `'drive'` (the default) or `'observe'`, which may only read
101
+ * the session. The host refuses anything else from an observer; this channel does not send it.
102
+ */
103
+ readonly role?: 'drive' | 'observe';
104
+ }
105
+
106
+ export type TWireTuiChannelOptions = IWireTuiChannelBaseOptions &
107
+ (
108
+ | {
109
+ /** The commands this terminal runs itself instead of sending them to the host. */
110
+ readonly clientCommands: ITuiClientCommands;
111
+ /** This terminal's working directory, where its own commands run. */
112
+ readonly cwd: string;
113
+ }
114
+ | { readonly clientCommands?: undefined }
115
+ );
116
+
117
+ /** Everything a view of the session reads beside its history, asked again after a session switch. */
118
+ const SNAPSHOT_REQUESTS = [
119
+ 'get-context',
120
+ 'get-commands',
121
+ 'get-status',
122
+ 'get-executing',
123
+ 'get-pending',
124
+ 'get-execution-workspace',
125
+ ] as const;
126
+ /**
127
+ * A question asked before this terminal attached is not sent to it again unless it asks. An observer
128
+ * never asks: it answers no question.
129
+ */
130
+ const DRIVER_SNAPSHOT_REQUESTS = [...SNAPSHOT_REQUESTS, 'get-prompts'] as const;
131
+ /** After a command the host may have changed the status, the context window or the catalog. */
132
+ const COMMAND_REFRESH_REQUESTS = ['get-context', 'get-status', 'get-commands'] as const;
133
+ /** In a shared session these would end it for everyone; here they only take this terminal away. */
134
+ const DETACH_COMMANDS: ReadonlySet<string> = new Set(['exit', 'quit']);
135
+ /** Screens that need this process's own plugin adapter or transport registry. */
136
+ const IN_PROCESS_SCREENS: ReadonlySet<IUiIntentEvent['intent']['type']> = new Set([
137
+ 'show-plugin-manager',
138
+ 'show-settings',
139
+ ]);
140
+ const ATTACHED = 'while attached to a daemon';
141
+ const READ_ONLY_NOTICE =
142
+ 'Read only: this terminal observes the session and cannot change it. /exit detaches.';
143
+
144
+ function errorMessage(error: unknown): string {
145
+ return error instanceof Error ? error.message : String(error);
146
+ }
147
+
148
+ /** The React-facing `ui_intent` / `session_renamed` port, fed from frames instead of a session. */
149
+ class WireSessionUiEvents implements ITuiSessionUiEventPort {
150
+ private readonly handlers: {
151
+ [E in TTuiSessionUiEventName]: Set<IInteractiveSessionEvents[E]>;
152
+ } = { ui_intent: new Set(), session_renamed: new Set() };
153
+
154
+ on<E extends TTuiSessionUiEventName>(event: E, handler: IInteractiveSessionEvents[E]): void {
155
+ this.handlers[event].add(handler);
156
+ }
157
+
158
+ off<E extends TTuiSessionUiEventName>(event: E, handler: IInteractiveSessionEvents[E]): void {
159
+ this.handlers[event].delete(handler);
160
+ }
161
+
162
+ emitUiIntent(event: IUiIntentEvent): void {
163
+ for (const handler of [...this.handlers.ui_intent]) handler(event);
164
+ }
165
+
166
+ emitSessionRenamed(event: ISessionRenamedEvent): void {
167
+ for (const handler of [...this.handlers.session_renamed]) handler(event);
168
+ }
169
+ }
170
+
171
+ export class WireTuiChannel implements ITuiAppChannelPort {
172
+ sessionName: string | undefined;
173
+
174
+ private manager: TuiStateManager;
175
+ private readonly userActions: TuiUserActionQueue;
176
+ private readonly permissions: TuiPermissionQueue;
177
+ private readonly attention: AttentionCoordinator | undefined;
178
+ private readonly lifecycle: TuiChannelLifecycleCoordinator;
179
+ private readonly uiEvents = new WireSessionUiEvents();
180
+ /** One host for every client command, so one terminal handoff at a time spans all of them. */
181
+ private readonly clientCommands:
182
+ { readonly set: ITuiClientCommands; readonly host: TTuiClientCommandHost } | undefined;
183
+ private readonly commandQueryPort: ITuiCommandQueryPort = {
184
+ getCommands: (filter) => filterCommandCatalog(this.commandCatalog, filter),
185
+ getSubcommands: (name) => findSubcommands(this.commandCatalog, name),
186
+ };
187
+ /** Only reads cross the wire from here, and nothing the user does changes the session. */
188
+ private readonly readOnly: boolean;
189
+ private commandCatalog: ICommand[] = [];
190
+ private status: ISessionStatusSnapshot | undefined;
191
+ /** How many prompts wait in the host's queue, the one `pendingPrompt` shows first among them. */
192
+ private pendingCount = 0;
193
+ private hostSessions: readonly ISessionListingEntry[] | undefined;
194
+ private sessionListSequence = 0;
195
+ /** Only the answer to the latest listing is shown: an earlier one may predate a switch. */
196
+ private sessionListRequestId: string | undefined;
197
+ /**
198
+ * The picker `/resume` asked for, opened on the host's answer: what this terminal held since it
199
+ * attached may be stale or missing, and an empty picker would only block the prompt.
200
+ */
201
+ private pendingPicker: IUiIntentEvent | undefined;
202
+ private readonly hostHistory: WireHistorySync;
203
+ /** Set when the channel attaches: history entries older than that are not this terminal's. */
204
+ private own: IOwnDriver | undefined;
205
+ /**
206
+ * Commands sent and not yet answered, by request id; `handleInput` settles when the host's answer
207
+ * is shown. The host echoes the id, so an answer settles only the command it answers.
208
+ */
209
+ private readonly pendingCommands = new Map<string, () => void>();
210
+ private commandSequence = 0;
211
+ /** Detail pages asked for and not yet answered, by request id. */
212
+ private readonly pendingDetails = new Map<
213
+ string,
214
+ { readonly resolve: (page: IExecutionDetailPage) => void; readonly reject: (error: Error) => void }
215
+ >();
216
+ private detailSequence = 0;
217
+ /** Esc's loop stops waiting for the host's outcome, by request id. */
218
+ private readonly pendingLoopStops = new Map<
219
+ string,
220
+ (outcome: TWaitingLoopStopOutcome | undefined) => void
221
+ >();
222
+ private loopStopSequence = 0;
223
+ /**
224
+ * Inputs sent to a background task, by task id, oldest first. The host's control result names the
225
+ * task but no request, so a result settles the oldest send to that task.
226
+ */
227
+ private readonly pendingTaskSends = new Map<string, ((message?: string) => void)[]>();
228
+ /**
229
+ * The switch the picker asked for, settled by the host's answer: `session_switched`, or a refusal
230
+ * that names its request (or, from an older host, a protocol error that names none). A refusal
231
+ * never settles a command.
232
+ */
233
+ private pendingSessionChange: { readonly requestId: string; readonly settle: () => void } | undefined;
234
+ private sessionChangeSequence = 0;
235
+ /** Bumped on a session switch: a question from a session no longer shown is never answered. */
236
+ private promptGeneration = 0;
237
+ /** Questions on screen or waiting for it: the host may send one again (`get-prompts`). */
238
+ private readonly shownPromptIds = new Set<string>();
239
+ /** Questions another client settled: dismissing them here answers nothing. */
240
+ private readonly settledPromptIds = new Set<string>();
241
+ /**
242
+ * Bumped when the transcript starts over for another session. The terminal prints its transcript
243
+ * once, counting what it printed, so a new one is printed from its start rather than past that count.
244
+ */
245
+ private transcriptGeneration = 0;
246
+ private readonly unsubscribers: (() => void)[] = [];
247
+ /** From here on nothing is sent: the terminal left, and the session is not this terminal's to end. */
248
+ private detached = false;
249
+ private ended = false;
250
+ private onChange: (() => void) | null = null;
251
+
252
+ constructor(private readonly options: TWireTuiChannelOptions) {
253
+ this.sessionName = options.sessionName;
254
+ this.readOnly = options.role === 'observe';
255
+ this.manager = this.createManager();
256
+ this.clientCommands =
257
+ options.clientCommands !== undefined
258
+ ? {
259
+ set: options.clientCommands,
260
+ host: createTuiClientCommandHost({
261
+ terminalHandoff: options.terminalHandoff,
262
+ cwd: options.cwd,
263
+ }),
264
+ }
265
+ : undefined;
266
+ this.hostHistory = new WireHistorySync({
267
+ request: (fromIndex) =>
268
+ this.send(fromIndex === 0 ? { type: 'get-history' } : { type: 'get-history', fromIndex }),
269
+ show: (entries, echoes) => this.manager.syncHostHistory(entries, echoes),
270
+ revive: (entries) => toHistoryEntries(entries, this.own),
271
+ });
272
+ this.userActions = new TuiUserActionQueue(() => this.notify());
273
+ this.permissions = new TuiPermissionQueue(() => this.notify());
274
+ this.attention = options.attention
275
+ ? new AttentionCoordinator({
276
+ source: options.attention,
277
+ onRecap: (line) => this.manager.addAttentionRecap(line),
278
+ })
279
+ : undefined;
280
+ this.lifecycle = new TuiChannelLifecycleCoordinator(
281
+ {
282
+ start: async () => this.attach(),
283
+ stop: async () => {
284
+ this.detach();
285
+ this.attention?.unwire();
286
+ this.onChange = null;
287
+ this.manager.dispose();
288
+ },
289
+ beginShutdown: () => {
290
+ this.detach();
291
+ this.notice('Detached. The session keeps running.');
292
+ },
293
+ // Leaving never ends the host's session: it is shared, and it keeps running without us.
294
+ shutdownSession: async () => undefined,
295
+ },
296
+ 0,
297
+ );
298
+ }
299
+
300
+ get terminalHandoffController(): TerminalHandoffController | undefined {
301
+ return this.options.terminalHandoff;
302
+ }
303
+
304
+ async start(): Promise<void> {
305
+ await this.lifecycle.start();
306
+ }
307
+
308
+ /** Detaches from the session; the connection belongs to whoever opened it. */
309
+ async stop(): Promise<void> {
310
+ await this.lifecycle.stop();
311
+ }
312
+
313
+ async shutdown(options?: { reason?: TSessionEndReason; timeoutMs?: number }): Promise<void> {
314
+ await this.lifecycle.shutdown(options);
315
+ }
316
+
317
+ subscribe(onChange: () => void): () => void {
318
+ this.onChange = onChange;
319
+ return () => {
320
+ if (this.onChange === onChange) this.onChange = null;
321
+ };
322
+ }
323
+
324
+ getSnapshot(): ITuiChannelSnapshot {
325
+ const manager = this.manager;
326
+ return {
327
+ history: manager.history,
328
+ streamingText: manager.streamingText,
329
+ activeTools: manager.activeTools,
330
+ isThinking: manager.isThinking,
331
+ isAborting: manager.isAborting,
332
+ lastErrorMessage: manager.lastErrorMessage,
333
+ isStalled: manager.isStalled,
334
+ sessionEventNotices: manager.sessionEventNotices,
335
+ isShuttingDown: this.lifecycle.isShuttingDown,
336
+ pendingPrompt: manager.pendingPrompt,
337
+ pendingCount: manager.pendingPrompt === null ? 0 : Math.max(this.pendingCount, 1),
338
+ executionWorkspaceSnapshot: manager.executionWorkspaceSnapshot,
339
+ ...(manager.selectedExecutionEntryId !== undefined
340
+ ? { selectedExecutionEntryId: manager.selectedExecutionEntryId }
341
+ : {}),
342
+ permissionRequest: this.permissions.current,
343
+ pendingUserAction: this.userActions.current,
344
+ contextState: manager.contextState,
345
+ ...(this.hostSessions !== undefined ? { hostSessions: this.hostSessions } : {}),
346
+ transcriptGeneration: this.transcriptGeneration,
347
+ ...(this.readOnly ? { readOnly: true } : {}),
348
+ attached: true,
349
+ };
350
+ }
351
+
352
+ getCommandQueryPort(): ITuiCommandQueryPort {
353
+ return this.commandQueryPort;
354
+ }
355
+
356
+ getSessionUiEventPort(): ITuiSessionUiEventPort {
357
+ return this.uiEvents;
358
+ }
359
+
360
+ getRuntimeStatusSnapshot(fallbackPermissionMode: TPermissionMode): ITuiRuntimeStatusSnapshot {
361
+ const status = this.status;
362
+ if (status === undefined) return { permissionMode: fallbackPermissionMode, sessionId: '' };
363
+ return {
364
+ permissionMode: status.permissionMode,
365
+ sessionId: status.sessionId,
366
+ effort: status.effort,
367
+ modelId: status.model,
368
+ };
369
+ }
370
+
371
+ addEntry(entry: IHistoryEntry): void {
372
+ this.manager.addEntry(entry);
373
+ }
374
+
375
+ async handleInput(input: string): Promise<void> {
376
+ if (!input.startsWith('/')) {
377
+ if (this.refuseReadOnly()) return;
378
+ // The host answers with its queue once it has taken the prompt.
379
+ this.send({ type: 'submit', prompt: input });
380
+ return;
381
+ }
382
+ const { name, args } = parseSlashCommandInput(input);
383
+ if (DETACH_COMMANDS.has(name)) {
384
+ this.end('user');
385
+ return;
386
+ }
387
+ const clientCommand = findTuiClientCommand(this.clientCommands?.set, name);
388
+ if (clientCommand !== undefined) {
389
+ await this.runClientCommand(clientCommand, args);
390
+ return;
391
+ }
392
+ if (this.refuseReadOnly()) return;
393
+ // Settles when the host's answer is on screen, so what reads the settings after a command
394
+ // (the status line, the theme) reads them after the host changed them.
395
+ this.commandSequence += 1;
396
+ const requestId = `wire-tui-command-${this.commandSequence}`;
397
+ await new Promise<void>((settle) => {
398
+ this.pendingCommands.set(requestId, settle);
399
+ if (!this.send({ type: 'command', name, args, requestId })) this.settleCommand(requestId);
400
+ });
401
+ }
402
+
403
+ /**
404
+ * Runs a terminal-owned command here and shows its answer as the in-process session would. It
405
+ * settles once what the command wrote is written: the App reads the appearance again after it.
406
+ */
407
+ private async runClientCommand(command: ITuiClientCommand, args: string): Promise<void> {
408
+ const commands = this.clientCommands;
409
+ if (commands === undefined) return;
410
+ try {
411
+ const { result, uiIntents } = await runTuiClientCommand(
412
+ command,
413
+ args,
414
+ commands.host,
415
+ commands.set,
416
+ );
417
+ for (const intent of uiIntents) {
418
+ this.uiEvents.emitUiIntent({ intent, requesterDriverId: OWNER_DRIVER_ID });
419
+ }
420
+ this.notice(result.message);
421
+ } catch (error) {
422
+ this.notice(`/${command.name} failed: ${errorMessage(error)}`);
423
+ }
424
+ }
425
+
426
+ abort(): void {
427
+ if (this.refuseReadOnly()) return;
428
+ this.manager.setAborting(true);
429
+ this.userActions.cancelAll();
430
+ this.permissions.cancelAll();
431
+ this.send({ type: 'abort' });
432
+ }
433
+
434
+ cancelQueue(): void {
435
+ if (this.refuseReadOnly()) return;
436
+ this.send({ type: 'cancel-queue' });
437
+ this.userActions.cancelAll();
438
+ this.permissions.cancelAll();
439
+ this.pendingCount = 0;
440
+ this.manager.setPendingPrompt(null);
441
+ }
442
+
443
+ /** Esc on an idle prompt: the host's session decides which waiting loop, if any, stops. */
444
+ async stopWaitingSelfPacedLoop(): Promise<void> {
445
+ if (this.refuseReadOnly()) return;
446
+ this.loopStopSequence += 1;
447
+ const requestId = `wire-tui-loop-stop-${this.loopStopSequence}`;
448
+ const outcome = await new Promise<TWaitingLoopStopOutcome | undefined>((settle) => {
449
+ this.pendingLoopStops.set(requestId, settle);
450
+ if (!this.send({ type: 'stop-waiting-loop', requestId })) this.settleLoopStop(requestId);
451
+ });
452
+ const notice = outcome === undefined ? undefined : waitingLoopStopNotice(outcome);
453
+ if (notice !== undefined) this.notice(notice);
454
+ }
455
+
456
+ selectExecutionWorkspaceEntry(entryId: string): void {
457
+ this.manager.selectExecutionWorkspaceEntry(entryId);
458
+ }
459
+
460
+ /** A page of what a workspace entry recorded, read from the host. An observer may read it too. */
461
+ async readExecutionWorkspaceDetail(entryId: string): Promise<IExecutionDetailPage> {
462
+ this.detailSequence += 1;
463
+ const requestId = `wire-tui-detail-${this.detailSequence}`;
464
+ return new Promise<IExecutionDetailPage>((resolve, reject) => {
465
+ this.pendingDetails.set(requestId, { resolve, reject });
466
+ if (!this.send({ type: 'read-execution-detail', requestId, entryId })) {
467
+ this.rejectDetail(requestId, 'Could not reach the session.');
468
+ }
469
+ });
470
+ }
471
+
472
+ /** Settles once the host says whether the task took the input; a refusal is shown as a notice. */
473
+ async sendAgentJob(taskId: string, input: string): Promise<void> {
474
+ if (this.refuseReadOnly()) return;
475
+ const failure = await new Promise<string | undefined>((settle) => {
476
+ const waiting = this.pendingTaskSends.get(taskId) ?? [];
477
+ waiting.push(settle);
478
+ this.pendingTaskSends.set(taskId, waiting);
479
+ if (!this.send({ type: 'send-background-task', taskId, input: { prompt: input } })) {
480
+ this.settleTaskSend(taskId);
481
+ }
482
+ });
483
+ if (failure !== undefined) this.notice(`Could not send to background task ${taskId}: ${failure}`);
484
+ }
485
+
486
+ resolveUserAction(request: IActionRequest, response: TActionResponse): void {
487
+ this.userActions.resolveCurrent(request, response);
488
+ }
489
+
490
+ /**
491
+ * Ask the host to show another session; its `session_switched` resets this channel. Settles on the
492
+ * host's answer, so the picker's switch is pending until the session changed or was refused.
493
+ */
494
+ async requestSessionSwitch(sessionId: string): Promise<void> {
495
+ if (this.refuseReadOnly()) return;
496
+ // The host answers a switch to the session already shown with nothing: there is nothing to wait for.
497
+ if (sessionId === this.status?.sessionId) return;
498
+ // An earlier switch still waiting is overtaken by this one.
499
+ this.settleSessionChange();
500
+ this.sessionChangeSequence += 1;
501
+ const requestId = `wire-tui-session-change-${this.sessionChangeSequence}`;
502
+ await new Promise<void>((settle) => {
503
+ this.pendingSessionChange = { requestId, settle };
504
+ if (!this.send({ type: 'switch-session', sessionId, requestId })) this.settleSessionChange();
505
+ });
506
+ }
507
+
508
+ // ── Frames → render state ─────────────────────────────────────
509
+
510
+ private attach(): void {
511
+ const { connection } = this.options;
512
+ if (this.options.driverId !== undefined) {
513
+ this.own = { driverId: this.options.driverId, since: Date.now() };
514
+ }
515
+ this.unsubscribers.push(
516
+ connection.subscribe((frame) => this.onFrame(frame)),
517
+ connection.onClose(() => this.end('closed')),
518
+ );
519
+ this.attention?.wire();
520
+ this.requestSnapshot();
521
+ }
522
+
523
+ private onFrame(frame: TServerMessage): void {
524
+ if (this.detached) return;
525
+ try {
526
+ this.project(frame);
527
+ } catch (error) {
528
+ // A frame this terminal cannot show must not take the connection's reader down with it.
529
+ this.notice(`Could not show the session's ${frame.type} update: ${errorMessage(error)}`);
530
+ }
531
+ }
532
+
533
+ private project(frame: TServerMessage): void {
534
+ const manager = this.manager;
535
+ switch (frame.type) {
536
+ case 'user_message': {
537
+ const driverId = displayDriverId(frame.driverId, this.options.driverId) ?? null;
538
+ this.hostHistory.echo(
539
+ attributedUserEcho(frame.content, { getActiveDriverId: () => driverId }),
540
+ frame.content,
541
+ );
542
+ return;
543
+ }
544
+ case 'text_delta':
545
+ manager.onTextDelta(frame.delta);
546
+ return;
547
+ case 'tool_start':
548
+ manager.onToolStart(frame.state);
549
+ return;
550
+ case 'tool_end':
551
+ manager.onToolEnd(frame.state);
552
+ return;
553
+ case 'thinking':
554
+ manager.onThinking(frame.isThinking);
555
+ // A turn starting or ending is when the host's queue moves.
556
+ this.send({ type: 'get-pending' });
557
+ return;
558
+ case 'executing':
559
+ if (frame.executing !== manager.isThinking) manager.onThinking(frame.executing);
560
+ return;
561
+ case 'complete':
562
+ manager.onComplete(frame.result);
563
+ this.attention?.onComplete();
564
+ // The answer is in the host's history now; only what came after the known entries is read.
565
+ this.hostHistory.readTail();
566
+ return;
567
+ case 'interrupted':
568
+ manager.onInterrupted();
569
+ // The host kept the partial answer; the next turn's prompt must not take its place.
570
+ this.hostHistory.readTail();
571
+ return;
572
+ case 'error':
573
+ manager.onError(new Error(frame.message));
574
+ this.attention?.onError();
575
+ this.hostHistory.readTail();
576
+ return;
577
+ case 'history':
578
+ this.hostHistory.onPage(frame);
579
+ return;
580
+ case 'history_changed':
581
+ this.hostHistory.reload();
582
+ return;
583
+ case 'history_cleared':
584
+ manager.clearHistory();
585
+ this.hostHistory.reset();
586
+ this.hostHistory.reload();
587
+ return;
588
+ case 'context':
589
+ manager.onContextUpdate(frame.state);
590
+ return;
591
+ case 'turn_source':
592
+ this.attention?.onTurnSource(frame.source);
593
+ return;
594
+ case 'pending':
595
+ this.pendingCount = frame.pendingCount ?? (frame.pending === null ? 0 : 1);
596
+ manager.setPendingPrompt(frame.pending);
597
+ return;
598
+ case 'execution_workspace_event':
599
+ manager.syncExecutionWorkspaceSnapshot(frame.snapshot);
600
+ this.attention?.onWorkspaceSnapshot(frame.snapshot);
601
+ return;
602
+ case 'plan_event':
603
+ manager.addSessionEventNotice({ event: 'plan_event', payload: frame.event });
604
+ return;
605
+ case 'context_file_refreshed':
606
+ manager.addSessionEventNotice({ event: 'context_file_refreshed', payload: frame.event });
607
+ return;
608
+ case 'branch_event':
609
+ manager.addSessionEventNotice({ event: 'branch_event', payload: frame.event });
610
+ return;
611
+ default:
612
+ this.projectSessionFrame(frame);
613
+ }
614
+ }
615
+
616
+ /** Frames about the session rather than its turn: questions, commands, status, switching. */
617
+ private projectSessionFrame(frame: TServerMessage): void {
618
+ switch (frame.type) {
619
+ case 'permission_request':
620
+ this.askPermission(frame.event);
621
+ return;
622
+ case 'ask_request':
623
+ this.askUser(frame.event);
624
+ return;
625
+ case 'prompt_resolved':
626
+ this.dismissPrompt(frame.event.id);
627
+ return;
628
+ case 'ui_intent':
629
+ this.showUiIntent(frame.event);
630
+ return;
631
+ case 'session_renamed':
632
+ this.rename(frame.event.name);
633
+ return;
634
+ case 'session_status':
635
+ this.applyStatus(frame.status);
636
+ return;
637
+ case 'commands':
638
+ this.commandCatalog = toCommandCatalog(frame.commands, frame.skills);
639
+ this.notify();
640
+ return;
641
+ case 'command_result':
642
+ this.showCommandResult(frame);
643
+ return;
644
+ case 'sessions':
645
+ if (frame.requestId !== this.sessionListRequestId) return;
646
+ this.hostSessions = frame.listing.sessions;
647
+ this.notify();
648
+ this.openPendingPicker();
649
+ return;
650
+ case 'sessions_error':
651
+ if (frame.requestId !== this.sessionListRequestId) return;
652
+ // Asked for at attach too, where a host without a session list is nothing to report.
653
+ if (this.pendingPicker !== undefined) {
654
+ this.pendingPicker = undefined;
655
+ this.notice(frame.message);
656
+ }
657
+ return;
658
+ case 'session_switched':
659
+ this.settleSessionChange();
660
+ this.followSessionSwitch(frame.event.sessionId);
661
+ return;
662
+ case 'session_change_failed':
663
+ // Only the client that asked is told. It answers the switch, never a command.
664
+ if (frame.requestId === undefined || frame.requestId === this.pendingSessionChange?.requestId) {
665
+ this.settleSessionChange();
666
+ }
667
+ this.notice(frame.message);
668
+ return;
669
+ case 'protocol_error':
670
+ // A command the host could not run answers with this, naming the command's request. A host
671
+ // older than `session_change_failed` refuses a switch with one that names no request.
672
+ if (frame.requestId !== undefined) this.settleCommand(frame.requestId);
673
+ else this.settleSessionChange();
674
+ this.notice(frame.message);
675
+ return;
676
+ case 'resume_gap':
677
+ // Frames were lost, perhaps the page a history read waits for: read everything again.
678
+ this.hostHistory.restart();
679
+ this.requestState();
680
+ return;
681
+ default:
682
+ this.projectReply(frame);
683
+ }
684
+ }
685
+
686
+ /** Answers to one request of this terminal's: a detail page, a loop stop, a task's input. */
687
+ private projectReply(frame: TServerMessage): void {
688
+ switch (frame.type) {
689
+ case 'execution_detail': {
690
+ const pending = this.pendingDetails.get(frame.requestId);
691
+ this.pendingDetails.delete(frame.requestId);
692
+ pending?.resolve(frame.page);
693
+ return;
694
+ }
695
+ case 'execution_detail_error':
696
+ this.rejectDetail(frame.requestId, frame.message);
697
+ return;
698
+ case 'waiting_loop_stop':
699
+ this.settleLoopStop(frame.requestId, frame.outcome);
700
+ return;
701
+ case 'background_task_control_result':
702
+ if (frame.action !== 'send') return;
703
+ this.settleTaskSend(frame.taskId, frame.success ? undefined : (frame.message ?? 'refused'));
704
+ return;
705
+ default:
706
+ // Other background-task and usage frames: the full TUI reads background work from the workspace.
707
+ return;
708
+ }
709
+ }
710
+
711
+ /** A question this terminal shows already is not shown twice (`get-prompts` sends it again). */
712
+ private showsPrompt(id: string): boolean {
713
+ if (this.shownPromptIds.has(id)) return true;
714
+ this.shownPromptIds.add(id);
715
+ return false;
716
+ }
717
+
718
+ private askPermission(event: IPermissionRequestEvent): void {
719
+ if (this.showsPrompt(event.id)) return;
720
+ this.attention?.onNeedsInput();
721
+ // A peer turn's ask names the peer, printed only as a plain identifier.
722
+ const requestedByPeer = event.requesterDriverId?.startsWith('peer:')
723
+ ? printablePeerDriver(event.requesterDriverId)
724
+ : undefined;
725
+ const generation = this.promptGeneration;
726
+ void this.permissions
727
+ .enqueue(
728
+ event.toolName,
729
+ event.toolArgs,
730
+ event.id,
731
+ event.canPersistProjectPermission,
732
+ requestedByPeer,
733
+ )
734
+ .then((result) =>
735
+ this.answer(generation, event.id, { type: 'permission-response', id: event.id, result }),
736
+ );
737
+ }
738
+
739
+ private askUser(event: IAskRequestEvent): void {
740
+ if (this.showsPrompt(event.id)) return;
741
+ this.attention?.onNeedsInput();
742
+ const generation = this.promptGeneration;
743
+ void this.userActions
744
+ .enqueue(event.request, event.id)
745
+ .then((response) =>
746
+ this.answer(generation, event.id, { type: 'ask-response', id: event.id, response }),
747
+ );
748
+ }
749
+
750
+ private answer(generation: number, id: string, message: TClientMessage): void {
751
+ if (generation !== this.promptGeneration) return;
752
+ this.shownPromptIds.delete(id);
753
+ if (this.settledPromptIds.delete(id)) return;
754
+ this.send(message);
755
+ }
756
+
757
+ /** Another client answered first: take the question off this screen without answering it. */
758
+ private dismissPrompt(id: string): void {
759
+ this.settledPromptIds.add(id);
760
+ const dismissedAction = this.userActions.dismissById(id);
761
+ const dismissedPermission = this.permissions.dismissById(id);
762
+ if (!dismissedAction && !dismissedPermission) this.settledPromptIds.delete(id);
763
+ }
764
+
765
+ private showUiIntent(event: IUiIntentEvent): void {
766
+ // An unattributed intent reaches every client; the App's own rule leaves it to its issuer.
767
+ if (event.requesterDriverId === undefined) {
768
+ this.uiEvents.emitUiIntent(event);
769
+ return;
770
+ }
771
+ if (IN_PROCESS_SCREENS.has(event.intent.type)) {
772
+ this.notice(`That screen is not available ${ATTACHED}.`);
773
+ return;
774
+ }
775
+ // The host routes an attributed intent only to the client that asked: this terminal's operator.
776
+ const own: IUiIntentEvent = { ...event, requesterDriverId: OWNER_DRIVER_ID };
777
+ if (event.intent.type === 'show-session-picker') {
778
+ this.pendingPicker = own;
779
+ if (!this.requestSessionList()) this.pendingPicker = undefined;
780
+ return;
781
+ }
782
+ this.uiEvents.emitUiIntent(own);
783
+ }
784
+
785
+ private openPendingPicker(): void {
786
+ const picker = this.pendingPicker;
787
+ if (picker === undefined) return;
788
+ this.pendingPicker = undefined;
789
+ this.uiEvents.emitUiIntent(picker);
790
+ }
791
+
792
+ private rename(name: string): void {
793
+ this.sessionName = name;
794
+ this.uiEvents.emitSessionRenamed({ name });
795
+ this.notify();
796
+ }
797
+
798
+ /** The session is labelled by its name, or by its id until it has one: never by the host's label. */
799
+ private applyStatus(status: ISessionStatusSnapshot): void {
800
+ this.status = status;
801
+ this.manager.onContextUpdate(status.context);
802
+ const label =
803
+ status.sessionName ??
804
+ (status.sessionId !== '' ? shortSessionId(status.sessionId) : undefined);
805
+ if (label !== undefined && label !== this.sessionName) this.rename(label);
806
+ }
807
+
808
+ private showCommandResult(frame: Extract<TServerMessage, { type: 'command_result' }>): void {
809
+ for (const type of COMMAND_REFRESH_REQUESTS) this.send({ type });
810
+ // A command that started a turn says nothing itself; the turn is its answer.
811
+ if (frame.data?.['sessionExecution'] === true) {
812
+ this.send({ type: 'get-pending' });
813
+ } else {
814
+ this.notice(frame.message);
815
+ }
816
+ if (frame.requestId !== undefined) this.settleCommand(frame.requestId);
817
+ }
818
+
819
+ private settleCommand(requestId: string): void {
820
+ const settle = this.pendingCommands.get(requestId);
821
+ this.pendingCommands.delete(requestId);
822
+ settle?.();
823
+ }
824
+
825
+ private rejectDetail(requestId: string, message: string): void {
826
+ const pending = this.pendingDetails.get(requestId);
827
+ this.pendingDetails.delete(requestId);
828
+ pending?.reject(new Error(message));
829
+ }
830
+
831
+ private settleLoopStop(requestId: string, outcome?: TWaitingLoopStopOutcome): void {
832
+ const settle = this.pendingLoopStops.get(requestId);
833
+ this.pendingLoopStops.delete(requestId);
834
+ settle?.(outcome);
835
+ }
836
+
837
+ /** Settles the oldest input sent to the task; `failure` is why the host refused it. */
838
+ private settleTaskSend(taskId: string, failure?: string): void {
839
+ const waiting = this.pendingTaskSends.get(taskId);
840
+ const settle = waiting?.shift();
841
+ if (waiting !== undefined && waiting.length === 0) this.pendingTaskSends.delete(taskId);
842
+ settle?.(failure);
843
+ }
844
+
845
+ /** An observer is told it cannot change the session; true when this terminal only observes. */
846
+ private refuseReadOnly(): boolean {
847
+ if (this.readOnly) this.notice(READ_ONLY_NOTICE);
848
+ return this.readOnly;
849
+ }
850
+
851
+ private settleSessionChange(): void {
852
+ const pending = this.pendingSessionChange;
853
+ this.pendingSessionChange = undefined;
854
+ pending?.settle();
855
+ }
856
+
857
+ /** The host now serves another session: nothing shown belongs to it, so start over from it. */
858
+ private followSessionSwitch(sessionId: string): void {
859
+ this.promptGeneration += 1;
860
+ this.userActions.cancelAll();
861
+ this.permissions.cancelAll();
862
+ this.shownPromptIds.clear();
863
+ this.settledPromptIds.clear();
864
+ this.status = undefined;
865
+ this.pendingCount = 0;
866
+ this.hostHistory.reset();
867
+ this.manager.dispose();
868
+ this.manager = this.createManager();
869
+ // A new transcript, printed from its start whatever this render also brings.
870
+ this.transcriptGeneration += 1;
871
+ this.notice(`Switched to session ${sessionId}.`);
872
+ // Until the new session's status names it.
873
+ this.rename(shortSessionId(sessionId));
874
+ this.requestSnapshot();
875
+ }
876
+
877
+ // ── Requests ──────────────────────────────────────────────────
878
+
879
+ private requestSnapshot(): void {
880
+ this.hostHistory.reload();
881
+ this.requestState();
882
+ }
883
+
884
+ /** Everything shown beside the history. */
885
+ private requestState(): void {
886
+ for (const type of this.readOnly ? SNAPSHOT_REQUESTS : DRIVER_SNAPSHOT_REQUESTS) {
887
+ this.send({ type });
888
+ }
889
+ this.requestSessionList();
890
+ }
891
+
892
+ private requestSessionList(): boolean {
893
+ this.sessionListSequence += 1;
894
+ const id = `wire-tui-sessions-${this.sessionListSequence}`;
895
+ this.sessionListRequestId = id;
896
+ return this.send({ type: 'list-sessions', requestId: id });
897
+ }
898
+
899
+ private send(message: TClientMessage): boolean {
900
+ if (this.detached) return false;
901
+ // The host would refuse it: an observer sends only reads.
902
+ if (this.readOnly && !isObserverMessageType(message.type)) return false;
903
+ try {
904
+ this.options.connection.send(message);
905
+ return true;
906
+ } catch (error) {
907
+ this.notice(`Could not reach the session: ${errorMessage(error)}`);
908
+ return false;
909
+ }
910
+ }
911
+
912
+ // ── Leaving ───────────────────────────────────────────────────
913
+
914
+ private end(reason: TAttachedSessionEnd): void {
915
+ if (this.ended) return;
916
+ this.ended = true;
917
+ this.detach();
918
+ this.notice(
919
+ reason === 'closed'
920
+ ? 'The connection to the session closed.'
921
+ : 'Detached. The session keeps running.',
922
+ );
923
+ this.options.onEnd?.(reason);
924
+ }
925
+
926
+ /** Stop listening and sending. An open question is dropped here and stays open for others. */
927
+ private detach(): void {
928
+ if (this.detached) return;
929
+ this.detached = true;
930
+ this.pendingPicker = undefined;
931
+ for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe();
932
+ this.userActions.cancelAll();
933
+ this.permissions.cancelAll();
934
+ const pending = [...this.pendingCommands.values()];
935
+ this.pendingCommands.clear();
936
+ for (const settle of pending) settle();
937
+ this.settleSessionChange();
938
+ for (const requestId of [...this.pendingDetails.keys()]) {
939
+ this.rejectDetail(requestId, 'Detached from the session.');
940
+ }
941
+ for (const requestId of [...this.pendingLoopStops.keys()]) this.settleLoopStop(requestId);
942
+ const taskSends = [...this.pendingTaskSends.values()].flat();
943
+ this.pendingTaskSends.clear();
944
+ for (const settle of taskSends) settle();
945
+ }
946
+
947
+ // ── Render state ──────────────────────────────────────────────
948
+
949
+ private createManager(): TuiStateManager {
950
+ const manager = new TuiStateManager();
951
+ manager.onChange = () => this.notify();
952
+ return manager;
953
+ }
954
+
955
+ private notice(text: string): void {
956
+ this.manager.addEntry(messageToHistoryEntry(createSystemMessage(text)));
957
+ }
958
+
959
+ private notify(): void {
960
+ this.onChange?.();
961
+ }
962
+ }