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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/README.md +5 -1
  3. package/dist/node/index.d.ts +189 -54
  4. package/dist/node/index.d.ts.map +1 -1
  5. package/dist/node/index.js +21 -21
  6. package/dist/node/index.js.map +1 -1
  7. package/package.json +16 -21
  8. package/src/App.tsx +14 -3
  9. package/src/AppPresentation.tsx +1 -1
  10. package/src/PermissionPrompt.tsx +50 -17
  11. package/src/SessionPicker.tsx +27 -7
  12. package/src/SessionStatusBar.tsx +6 -2
  13. package/src/StatusBar.tsx +24 -0
  14. package/src/SupervisedSessionView.tsx +479 -157
  15. package/src/TuiInteractionChannel.ts +8 -53
  16. package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
  17. package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
  18. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
  19. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
  20. package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
  21. package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
  22. package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
  23. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
  24. package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
  25. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  26. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  27. package/src/__tests__/palette-consistency.test.ts +0 -1
  28. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  29. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
  30. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
  31. package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
  32. package/src/__tests__/render-channel-options.test.ts +6 -6
  33. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  34. package/src/__tests__/screen-reader-menus.test.tsx +1 -1
  35. package/src/__tests__/session-picker.test.tsx +93 -0
  36. package/src/__tests__/status-bar.test.tsx +8 -0
  37. package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
  38. package/src/__tests__/wire-tui-channel.test.ts +1445 -0
  39. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  40. package/src/app-view-model.ts +6 -2
  41. package/src/attached-session-connection.ts +14 -0
  42. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  43. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
  44. package/src/hooks/useAppController.ts +6 -2
  45. package/src/hooks/useAppInputBindings.ts +20 -2
  46. package/src/hooks/useAppInteractionState.ts +3 -0
  47. package/src/hooks/useAppOverlays.ts +20 -4
  48. package/src/hooks/useNumberedSelection.ts +19 -2
  49. package/src/hooks/useTuiChannel.ts +13 -1
  50. package/src/index.ts +17 -3
  51. package/src/render.tsx +218 -27
  52. package/src/short-session-id.ts +9 -0
  53. package/src/slash-command-input.ts +11 -0
  54. package/src/tui-app-channel-port.ts +29 -1
  55. package/src/tui-channel-options.ts +11 -2
  56. package/src/tui-session-event-projector.ts +0 -2
  57. package/src/tui-session-events.ts +6 -0
  58. package/src/tui-session-options.ts +9 -0
  59. package/src/tui-state-manager.ts +17 -3
  60. package/src/waiting-loop-stop-notice.ts +23 -0
  61. package/src/wire-history-sync.ts +159 -0
  62. package/src/wire-tui-channel.ts +962 -0
  63. package/src/wire-tui-client-commands.ts +142 -0
  64. package/src/wire-tui-projection.ts +136 -0
  65. package/dist/node/index.cjs +0 -32
  66. package/dist/node/index.d.cts +0 -1066
  67. package/dist/node/index.d.cts.map +0 -1
@@ -0,0 +1,243 @@
1
+ /**
2
+ * #3189 — the commands a terminal attached to a workspace daemon runs in its own process.
3
+ *
4
+ * The attached terminal gives them its own handoff and working directory, applies the appearance
5
+ * patch `/theme` asks for itself, and refuses any other host action by name instead of dropping it.
6
+ */
7
+ import { createTerminalClientCommands } from '@robota-sdk/agent-command';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+
10
+ import {
11
+ createTuiClientCommandHost,
12
+ findTuiClientCommand,
13
+ runTuiClientCommand,
14
+ } from '../wire-tui-client-commands.js';
15
+
16
+ import type {
17
+ ITuiClientCommand,
18
+ ITuiClientCommands,
19
+ TTuiClientCommandHost,
20
+ } from '../wire-tui-client-commands.js';
21
+ import type {
22
+ ICommandResult,
23
+ IThemeCatalogueEntry,
24
+ IThemeCataloguePort,
25
+ TAppearanceSettingsPatch,
26
+ TCommandHostAction,
27
+ } from '@robota-sdk/agent-interface-command';
28
+ import type { ITerminalHandoff } from '@robota-sdk/agent-interface-session';
29
+
30
+ /** Stands in for `/shell`: hands the terminal to a "child" that runs in the host's directory. */
31
+ const shellLike: ITuiClientCommand = {
32
+ name: 'shell',
33
+ async execute(host, args) {
34
+ if (!host.canHandoffTerminal()) {
35
+ return { success: false, message: 'An interactive shell is unavailable here.' };
36
+ }
37
+ const cwd = host.getCwd();
38
+ const ran = await host.runWithTerminal(async () => `${args} in ${cwd}`);
39
+ return { success: true, message: ran };
40
+ },
41
+ };
42
+
43
+ function commandReturning(name: string, result: ICommandResult): ITuiClientCommand {
44
+ return { name, execute: () => result };
45
+ }
46
+
47
+ function hostFor(terminalHandoff?: ITerminalHandoff): TTuiClientCommandHost {
48
+ return createTuiClientCommandHost({ terminalHandoff, cwd: '/work/project' });
49
+ }
50
+
51
+ function writerSpy() {
52
+ return {
53
+ writeAppearanceSettings: vi.fn(async (_patch: TAppearanceSettingsPatch) => undefined),
54
+ };
55
+ }
56
+
57
+ const THEMES: IThemeCatalogueEntry[] = [
58
+ { id: 'dark', name: 'Dark', appearance: 'dark', source: 'built-in' },
59
+ { id: 'light', name: 'Light', appearance: 'light', source: 'built-in' },
60
+ ];
61
+ const themeCatalogue: IThemeCataloguePort = {
62
+ listThemes: () => THEMES,
63
+ getTheme: (id) => THEMES.find((theme) => theme.id === id),
64
+ getAppearance: () => ({
65
+ settings: { theme: 'dark', syntaxHighlighting: true, reducedMotion: false },
66
+ }),
67
+ };
68
+
69
+ describe('createTuiClientCommandHost (#3189)', () => {
70
+ it('hands this terminal to the command and runs it in this terminal’s directory', async () => {
71
+ const handedOff = vi.fn();
72
+ const handoff: ITerminalHandoff = {
73
+ canHandoffTerminal: true,
74
+ runWithTerminal: <T>(fn: () => Promise<T>): Promise<T> => {
75
+ handedOff();
76
+ return fn();
77
+ },
78
+ };
79
+ const host = hostFor(handoff);
80
+
81
+ const outcome = await runTuiClientCommand(shellLike, 'ls', host, writerSpy());
82
+
83
+ expect(handedOff).toHaveBeenCalledTimes(1);
84
+ expect(outcome.result).toEqual({ success: true, message: 'ls in /work/project' });
85
+ expect(host.getCommandInvocationSource()).toBe('user');
86
+ });
87
+
88
+ it('without a handoff it cannot take the terminal, so the command takes its refusal path', async () => {
89
+ const host = hostFor(undefined);
90
+
91
+ expect(host.canHandoffTerminal()).toBe(false);
92
+ const outcome = await runTuiClientCommand(shellLike, 'ls', host, writerSpy());
93
+
94
+ expect(outcome.result).toEqual({
95
+ success: false,
96
+ message: 'An interactive shell is unavailable here.',
97
+ });
98
+ await expect(host.runWithTerminal(async () => 'never')).rejects.toThrow(/unavailable/u);
99
+ });
100
+ });
101
+
102
+ describe('runTuiClientCommand (#3189)', () => {
103
+ it('/theme with no arguments opens this terminal’s theme picker and writes nothing', async () => {
104
+ const theme = commandReturning('theme', {
105
+ success: true,
106
+ message: 'Opening the theme picker...',
107
+ uiIntents: [{ type: 'show-theme-picker' }],
108
+ });
109
+ const writers = writerSpy();
110
+
111
+ const outcome = await runTuiClientCommand(theme, '', hostFor(), writers);
112
+
113
+ expect(outcome.uiIntents).toEqual([{ type: 'show-theme-picker' }]);
114
+ expect(outcome.result).toEqual({ success: true, message: 'Opening the theme picker...' });
115
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('/theme light, from the real client command set, is written before the command settles', async () => {
119
+ let finishWrite: () => void = () => undefined;
120
+ const written: TAppearanceSettingsPatch[] = [];
121
+ const commands: ITuiClientCommands = {
122
+ commands: createTerminalClientCommands({ themeCatalogue }),
123
+ writeAppearanceSettings: (patch) => {
124
+ written.push(patch);
125
+ return new Promise<void>((resolve) => {
126
+ finishWrite = resolve;
127
+ });
128
+ },
129
+ };
130
+ const theme = findTuiClientCommand(commands, 'theme');
131
+ if (!theme) throw new Error('the client command set has no /theme');
132
+
133
+ let settled = false;
134
+ const running = runTuiClientCommand(theme, 'light', hostFor(), commands).then((outcome) => {
135
+ settled = true;
136
+ return outcome;
137
+ });
138
+ await vi.waitFor(() => expect(written).toEqual([{ theme: 'light' }]));
139
+ await new Promise((resolve) => setImmediate(resolve));
140
+ expect(settled).toBe(false);
141
+
142
+ finishWrite();
143
+ const outcome = await running;
144
+
145
+ expect(outcome.result.success).toBe(true);
146
+ expect(outcome.result.hostActions).toBeUndefined();
147
+ expect(outcome.result.data).toEqual({ appearance: { theme: 'light' } });
148
+ expect(outcome.uiIntents).toEqual([]);
149
+ });
150
+
151
+ it('an invalid appearance patch writes nothing and fails', async () => {
152
+ const command = commandReturning('theme', {
153
+ success: true,
154
+ message: 'Applied.',
155
+ hostActions: [
156
+ {
157
+ type: 'appearance-settings-patch',
158
+ patch: { reducedMotion: 'yes' } as unknown as TAppearanceSettingsPatch,
159
+ },
160
+ ],
161
+ });
162
+ const writers = writerSpy();
163
+
164
+ const outcome = await runTuiClientCommand(command, 'x', hostFor(), writers);
165
+
166
+ expect(outcome.result.success).toBe(false);
167
+ expect(outcome.result.message).toContain('invalid appearance settings patch');
168
+ expect(outcome.uiIntents).toEqual([]);
169
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('a host action this terminal cannot perform is refused by name, and nothing beside it is written', async () => {
173
+ const hostActions: TCommandHostAction[] = [
174
+ { type: 'appearance-settings-patch', patch: { theme: 'light' } },
175
+ { type: 'session-exit' },
176
+ ];
177
+ const command = commandReturning('theme', {
178
+ success: true,
179
+ message: 'Applied.',
180
+ hostActions,
181
+ uiIntents: [{ type: 'show-theme-picker' }],
182
+ });
183
+ const writers = writerSpy();
184
+
185
+ const outcome = await runTuiClientCommand(command, '', hostFor(), writers);
186
+
187
+ expect(outcome).toEqual({
188
+ result: { success: false, message: "Cannot apply 'session-exit' on this terminal." },
189
+ uiIntents: [],
190
+ });
191
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('a write that fails turns the result into that failure', async () => {
195
+ const command = commandReturning('theme', {
196
+ success: true,
197
+ message: 'Applied.',
198
+ hostActions: [{ type: 'appearance-settings-patch', patch: { theme: 'light' } }],
199
+ });
200
+
201
+ const outcome = await runTuiClientCommand(command, 'light', hostFor(), {
202
+ writeAppearanceSettings: () => Promise.reject(new Error('disk full')),
203
+ });
204
+
205
+ expect(outcome.result).toEqual({
206
+ success: false,
207
+ message: "Failed to apply 'appearance-settings-patch': disk full",
208
+ });
209
+ });
210
+
211
+ it('a failed result passes through unchanged and applies nothing', async () => {
212
+ const failed: ICommandResult = {
213
+ success: false,
214
+ message: 'Unknown theme "nope".',
215
+ hostActions: [{ type: 'appearance-settings-patch', patch: { theme: 'nope' } }],
216
+ };
217
+ const writers = writerSpy();
218
+
219
+ const outcome = await runTuiClientCommand(
220
+ commandReturning('theme', failed),
221
+ 'nope',
222
+ hostFor(),
223
+ writers,
224
+ );
225
+
226
+ expect(outcome.result).toBe(failed);
227
+ expect(outcome.uiIntents).toEqual([]);
228
+ expect(writers.writeAppearanceSettings).not.toHaveBeenCalled();
229
+ });
230
+ });
231
+
232
+ describe('findTuiClientCommand (#3189)', () => {
233
+ it('finds a client command by name, and nothing without a set', () => {
234
+ const commands: ITuiClientCommands = {
235
+ commands: [shellLike],
236
+ writeAppearanceSettings: () => undefined,
237
+ };
238
+
239
+ expect(findTuiClientCommand(commands, 'shell')).toBe(shellLike);
240
+ expect(findTuiClientCommand(commands, 'model')).toBeUndefined();
241
+ expect(findTuiClientCommand(undefined, 'shell')).toBeUndefined();
242
+ });
243
+ });
@@ -21,13 +21,15 @@ import type {
21
21
  IExecutionWorkspaceSnapshot,
22
22
  } from '@robota-sdk/agent-interface-execution';
23
23
  import type {
24
- IResumableSessionSummary,
24
+ ISessionListingEntry,
25
25
  IToolState,
26
26
  } from '@robota-sdk/agent-interface-session';
27
27
  import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
28
28
 
29
29
  export interface IAppViewModel {
30
30
  readonly staticItems: TStaticItem[];
31
+ /** Printing starts over from the first item when this changes (see `transcriptGeneration`). */
32
+ readonly transcriptKey: number;
31
33
  readonly handoffSuspended: boolean;
32
34
  readonly updateNotice: string | undefined;
33
35
  readonly coordinationError: string | undefined;
@@ -89,7 +91,7 @@ export interface IAppTransportViewModel {
89
91
 
90
92
  export interface IAppSessionPickerViewModel {
91
93
  readonly visible: boolean;
92
- readonly sessions: readonly IResumableSessionSummary[];
94
+ readonly sessions: readonly ISessionListingEntry[];
93
95
  readonly select: (sessionId: string) => void;
94
96
  readonly cancel: () => void;
95
97
  }
@@ -133,4 +135,6 @@ export interface IAppStatusViewModel {
133
135
  readonly activePresetId: string | undefined;
134
136
  readonly effort: TModelEffortSelection | undefined;
135
137
  readonly gitRefreshToken: number;
138
+ /** This terminal only observes the session; the status bar says so. */
139
+ readonly readOnly: boolean;
136
140
  }
@@ -0,0 +1,14 @@
1
+ import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport/client';
2
+
3
+ /**
4
+ * A terminal's end of the session protocol to a session it is attached to. The terminal only sends
5
+ * and listens; closing the connection belongs to whoever opened it.
6
+ */
7
+ export interface IAttachedSessionConnection {
8
+ send(message: TClientMessage): void;
9
+ subscribe(listener: (message: TServerMessage) => void): () => void;
10
+ onClose(listener: () => void): () => void;
11
+ }
12
+
13
+ /** Why an attached terminal stopped showing the session: the user left, or the connection closed. */
14
+ export type TAttachedSessionEnd = 'user' | 'closed';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Ctrl-] detaches a terminal attached to a session a host runs, exactly like Ctrl-C and `/exit`
3
+ * there. A terminal running its own session has nothing to detach from, so the key does nothing.
4
+ */
5
+
6
+ import React from 'react';
7
+ import { Text } from 'ink';
8
+ import { cleanup, render } from 'ink-testing-library';
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+
11
+ import { useAppInputBindings } from '../useAppInputBindings.js';
12
+
13
+ type TOptions = Parameters<typeof useAppInputBindings>[0];
14
+
15
+ const CTRL_RIGHT_BRACKET = '\x1d';
16
+
17
+ function Probe({ options }: { options: TOptions }): React.ReactElement {
18
+ useAppInputBindings(options);
19
+ return <Text>ready</Text>;
20
+ }
21
+
22
+ function options(overrides: Partial<TOptions> = {}): TOptions {
23
+ return {
24
+ isThinking: false,
25
+ isShuttingDown: false,
26
+ readOnly: false,
27
+ attached: false,
28
+ permissionRequest: null,
29
+ pendingUserAction: null,
30
+ pluginVisible: false,
31
+ transportVisible: false,
32
+ sessionPickerVisible: false,
33
+ workspaceSwitcherVisible: false,
34
+ historySearchOpen: false,
35
+ themePickerVisible: false,
36
+ selectedEntry: undefined,
37
+ backgroundListFocused: false,
38
+ mainThreadEntryId: undefined,
39
+ activeTools: [],
40
+ abort: vi.fn(),
41
+ stopWaitingLoop: vi.fn().mockResolvedValue(undefined),
42
+ toggleWorkspaceSwitcher: vi.fn(),
43
+ selectWorkspaceEntry: vi.fn(),
44
+ shutdown: vi.fn().mockResolvedValue(undefined),
45
+ recoveryError: undefined,
46
+ recoveryPending: false,
47
+ coordinationBlocked: false,
48
+ retryRecovery: vi.fn(),
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ afterEach(cleanup);
54
+
55
+ describe('Ctrl-] detach', () => {
56
+ it('leaves an attached terminal the way Ctrl-C does, driving or observing', async () => {
57
+ for (const attached of [options({ attached: true }), options({ attached: true, readOnly: true })]) {
58
+ const view = render(<Probe options={attached} />);
59
+ view.stdin.write(CTRL_RIGHT_BRACKET);
60
+ await vi.waitFor(() => expect(attached.shutdown).toHaveBeenCalledWith('prompt_input_exit'));
61
+ view.unmount();
62
+ }
63
+ });
64
+
65
+ it('does nothing on a terminal running its own session', async () => {
66
+ const own = options();
67
+ const view = render(<Probe options={own} />);
68
+ view.stdin.write(CTRL_RIGHT_BRACKET);
69
+ await new Promise<void>((resolve) => setTimeout(resolve, 200));
70
+ expect(own.shutdown).not.toHaveBeenCalled();
71
+ });
72
+ });
@@ -16,6 +16,8 @@ function options(overrides: Partial<TOptions> = {}): TOptions {
16
16
  return {
17
17
  isThinking: false,
18
18
  isShuttingDown: false,
19
+ readOnly: false,
20
+ attached: false,
19
21
  permissionRequest: null,
20
22
  pendingUserAction: null,
21
23
  pluginVisible: false,
@@ -71,4 +73,15 @@ describe('Esc for a waiting self-paced loop', () => {
71
73
  view.unmount();
72
74
  }
73
75
  });
76
+
77
+ it('stops nothing on an observing terminal: neither the turn nor the loop', async () => {
78
+ for (const observing of [options({ readOnly: true }), options({ readOnly: true, isThinking: true })]) {
79
+ const view = render(<Probe options={observing} />);
80
+ view.stdin.write('\u001b');
81
+ await new Promise<void>((resolve) => setTimeout(resolve, 200));
82
+ expect(observing.stopWaitingLoop).not.toHaveBeenCalled();
83
+ expect(observing.abort).not.toHaveBeenCalled();
84
+ view.unmount();
85
+ }
86
+ });
74
87
  });
@@ -113,7 +113,9 @@ function buildInput(composition: IComposition): IAppInputViewModel {
113
113
  submit: composition.interaction.submission.submit,
114
114
  cancelQueue: state.handleCancelQueue,
115
115
  disabled: interactionBlocked || (state.isThinking && state.pendingPrompt !== null),
116
- queueCancellationDisabled: interactionBlocked,
116
+ // An observer keeps the composer for `/exit` and this terminal's own commands, which the channel
117
+ // runs; the queue it would cancel is the drivers'.
118
+ queueCancellationDisabled: interactionBlocked || state.readOnly,
117
119
  isAborting: state.isAborting,
118
120
  pendingPrompt: state.pendingPrompt,
119
121
  pendingCount: state.pendingCount,
@@ -131,7 +133,7 @@ function buildStatus(composition: IComposition): IAppStatusViewModel {
131
133
  return {
132
134
  cwd: props.cwd,
133
135
  permissionMode: runtime.permissionMode,
134
- modelId: props.modelId,
136
+ modelId: runtime.modelId ?? props.modelId,
135
137
  providerType: props.providerType,
136
138
  sessionId: runtime.sessionId,
137
139
  isThinking: state.isThinking,
@@ -145,6 +147,7 @@ function buildStatus(composition: IComposition): IAppStatusViewModel {
145
147
  activePresetId: runtime.activePresetId,
146
148
  effort: runtime.effort,
147
149
  gitRefreshToken: composition.interaction.submission.gitRefreshToken,
150
+ readOnly: state.readOnly,
148
151
  };
149
152
  }
150
153
 
@@ -152,6 +155,7 @@ function buildViewModel(composition: IComposition): IAppViewModel {
152
155
  const { interaction, lifecycle, shell, state } = composition;
153
156
  return {
154
157
  staticItems: composition.staticItems,
158
+ transcriptKey: state.transcriptGeneration,
155
159
  handoffSuspended: lifecycle.handoffSuspended,
156
160
  updateNotice: lifecycle.updateNotice,
157
161
  coordinationError: composition.coordination.error,
@@ -14,6 +14,10 @@ import type { IToolState } from '@robota-sdk/agent-interface-session';
14
14
  interface IOptions {
15
15
  readonly isThinking: boolean;
16
16
  readonly isShuttingDown: boolean;
17
+ /** An observer stops nothing: neither the drivers' turn nor their loop. */
18
+ readonly readOnly: boolean;
19
+ /** Attached to a session a host runs: Ctrl-] detaches, like Ctrl-C and `/exit` there. */
20
+ readonly attached: boolean;
17
21
  readonly permissionRequest: IPendingPermissionRequest | null;
18
22
  readonly pendingUserAction: IActionRequest | null;
19
23
  readonly pluginVisible: boolean;
@@ -58,7 +62,7 @@ function useEscapeBindings(options: IOptions): void {
58
62
  useKeybindingActions(context, (actions) => {
59
63
  if (overlaysBlockKeys(options)) return;
60
64
  if (actions.includes('abort') && options.isThinking) {
61
- options.abort();
65
+ if (!options.readOnly) options.abort();
62
66
  return;
63
67
  }
64
68
  const selected = options.selectedEntry;
@@ -71,7 +75,11 @@ function useEscapeBindings(options: IOptions): void {
71
75
  options.selectWorkspaceEntry(options.mainThreadEntryId);
72
76
  return;
73
77
  }
74
- if (actions.includes('return-to-main') && !options.backgroundListFocused) {
78
+ if (
79
+ actions.includes('return-to-main') &&
80
+ !options.backgroundListFocused &&
81
+ !options.readOnly
82
+ ) {
75
83
  void options.stopWaitingLoop();
76
84
  }
77
85
  });
@@ -107,6 +115,13 @@ function useRecoveryBinding(options: IOptions): void {
107
115
  });
108
116
  }
109
117
 
118
+ /** Ctrl-]: the raw byte, or `]` with ctrl where the terminal reports modifiers. */
119
+ const DETACH_BYTE = '\x1d';
120
+
121
+ function isDetachKey(input: string, ctrl: boolean): boolean {
122
+ return input === DETACH_BYTE || (ctrl && input === ']');
123
+ }
124
+
110
125
  function useShutdownBindings(options: IOptions): void {
111
126
  const { exit } = useApp();
112
127
  const requestShutdown = (reason: TSessionEndReason): void => {
@@ -117,6 +132,9 @@ function useShutdownBindings(options: IOptions): void {
117
132
  };
118
133
  useInput((input, key) => {
119
134
  if (key.ctrl && input === 'c') requestShutdown('prompt_input_exit');
135
+ // Ctrl-] is the attach client's detach key. On a terminal running its own session there is
136
+ // nothing to detach from, and quitting that session is left to Ctrl-C and `/exit`.
137
+ else if (options.attached && isDetachKey(input, key.ctrl)) requestShutdown('prompt_input_exit');
120
138
  });
121
139
  useEffect(() => {
122
140
  const onSignal = (): void => requestShutdown('other');
@@ -41,6 +41,8 @@ export function useAppInteractionState(options: IOptions): IAppInteractionState
41
41
  const screenReader = useAppInputBindings({
42
42
  isThinking: options.state.isThinking,
43
43
  isShuttingDown: options.state.isShuttingDown,
44
+ readOnly: options.state.readOnly,
45
+ attached: options.state.attached,
44
46
  permissionRequest: options.state.permissionRequest,
45
47
  pendingUserAction: options.state.pendingUserAction,
46
48
  pluginVisible: screens.showPluginTUI,
@@ -72,6 +74,7 @@ export function useAppInteractionState(options: IOptions): IAppInteractionState
72
74
  transportVisible: screens.showTransportTUI,
73
75
  setTransportVisible: screens.setShowTransportTUI,
74
76
  sessionStore: options.sessionStore,
77
+ hostSessions: options.state.hostSessions,
75
78
  sessionPickerVisible: screens.showSessionPicker,
76
79
  setSessionPickerVisible: screens.setShowSessionPicker,
77
80
  onSessionSwitch: options.onSessionSwitch,
@@ -1,6 +1,6 @@
1
1
  import { createSystemMessage, messageToHistoryEntry } from '@robota-sdk/agent-core';
2
2
  import { listResumableSessionSummaries } from '@robota-sdk/agent-framework';
3
- import { useCallback } from 'react';
3
+ import { useCallback, useEffect } from 'react';
4
4
 
5
5
  import { usePluginCallbacks } from './usePluginCallbacks.js';
6
6
 
@@ -11,7 +11,10 @@ import type {
11
11
  } from '../app-view-model.js';
12
12
  import type { IHistoryEntry } from '@robota-sdk/agent-core';
13
13
  import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
14
- import type { IInteractiveSessionStore } from '@robota-sdk/agent-interface-session';
14
+ import type {
15
+ IInteractiveSessionStore,
16
+ ISessionListingEntry,
17
+ } from '@robota-sdk/agent-interface-session';
15
18
  import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
16
19
 
17
20
  interface IOptions {
@@ -23,12 +26,16 @@ interface IOptions {
23
26
  readonly transportVisible: boolean;
24
27
  readonly setTransportVisible: (visible: boolean) => void;
25
28
  readonly sessionStore: IInteractiveSessionStore | undefined;
29
+ /** The host's sessions, when the channel's host keeps them; they replace the local store's. */
30
+ readonly hostSessions: readonly ISessionListingEntry[] | undefined;
26
31
  readonly sessionPickerVisible: boolean;
27
32
  readonly setSessionPickerVisible: (visible: boolean) => void;
28
33
  readonly onSessionSwitch: (sessionId: string) => Promise<void>;
29
34
  readonly addEntry: (entry: IHistoryEntry) => void;
30
35
  }
31
36
 
37
+ const NO_SESSIONS_NOTICE = 'No saved sessions to resume in this workspace.';
38
+
32
39
  export interface IAppOverlays {
33
40
  readonly plugin: IAppPluginViewModel;
34
41
  readonly transport: IAppTransportViewModel;
@@ -52,6 +59,15 @@ export function useAppOverlays(options: IOptions): IAppOverlays {
52
59
  options.setSessionPickerVisible(false);
53
60
  options.addEntry(messageToHistoryEntry(createSystemMessage('Session resume cancelled.')));
54
61
  }, [options.addEntry, options.setSessionPickerVisible]);
62
+ const sessions =
63
+ options.hostSessions ?? listResumableSessionSummaries(options.sessionStore, options.cwd);
64
+ // A picker with nothing to pick would only hold the prompt: say so and give the prompt back.
65
+ const nothingToResume = options.sessionPickerVisible && sessions.length === 0;
66
+ useEffect(() => {
67
+ if (!nothingToResume) return;
68
+ options.setSessionPickerVisible(false);
69
+ options.addEntry(messageToHistoryEntry(createSystemMessage(NO_SESSIONS_NOTICE)));
70
+ }, [nothingToResume, options.addEntry, options.setSessionPickerVisible]);
55
71
  return {
56
72
  plugin: {
57
73
  visible: options.pluginVisible,
@@ -65,8 +81,8 @@ export function useAppOverlays(options: IOptions): IAppOverlays {
65
81
  close: () => options.setTransportVisible(false),
66
82
  },
67
83
  sessionPicker: {
68
- visible: options.sessionPickerVisible,
69
- sessions: listResumableSessionSummaries(options.sessionStore, options.cwd),
84
+ visible: options.sessionPickerVisible && !nothingToResume,
85
+ sessions,
70
86
  select: selectSession,
71
87
  cancel: cancelSessionPicker,
72
88
  },
@@ -40,7 +40,8 @@ export interface INumberedSelection {
40
40
  export function useNumberedSelection(inputs: IUseNumberedSelectionInputs): INumberedSelection {
41
41
  const [state, setState] = useState<INumericSelectionState>(createNumericSelectionState);
42
42
  const stateRef = useRef(state);
43
- const { enabled, itemCount, cancellable, multi, repeatable, onSelect, onCancel, onConfirm } = inputs;
43
+ const { enabled, itemCount, cancellable, multi, repeatable, onSelect, onCancel, onConfirm } =
44
+ inputs;
44
45
 
45
46
  const clear = useCallback((): void => {
46
47
  const empty = createNumericSelectionState();
@@ -49,7 +50,7 @@ export function useNumberedSelection(inputs: IUseNumberedSelectionInputs): INumb
49
50
  }, []);
50
51
 
51
52
  const handle = useCallback(
52
- (input: string, key: Parameters<typeof applyNumericSelection>[2]): void => {
53
+ (input: string, key: Parameters<typeof applyNumericSelection>[2]): boolean => {
53
54
  const result = applyNumericSelection(stateRef.current, input, key, {
54
55
  itemCount,
55
56
  ...(cancellable === true ? { cancellable: true } : {}),
@@ -61,12 +62,28 @@ export function useNumberedSelection(inputs: IUseNumberedSelectionInputs): INumb
61
62
  if (result.effect.type === 'select') onSelect(result.effect.index);
62
63
  else if (result.effect.type === 'cancel') onCancel?.();
63
64
  else if (result.effect.type === 'confirm') onConfirm?.();
65
+ return result.effect.type !== 'none';
64
66
  },
65
67
  [itemCount, cancellable, multi, repeatable, onSelect, onCancel, onConfirm],
66
68
  );
67
69
 
68
70
  useInput(
69
71
  (input, key) => {
72
+ // Pasted or fast-typed input can arrive as one chunk ("2\r"): Ink then reports it as plain
73
+ // text with no return key. Replay it as the keystrokes it contains so the Enter is not lost,
74
+ // but only up to the first keystroke that acts: the menu's callbacks see the state of the
75
+ // current render, so acting twice within one chunk could decide on stale state.
76
+ if (key.return !== true && /[\r\n]/u.test(input)) {
77
+ const plainKey = { ...key, return: false };
78
+ for (const part of input.split(/(\r\n|\r|\n)/u)) {
79
+ if (part === '') continue;
80
+ const acted = /^(\r\n|\r|\n)$/u.test(part)
81
+ ? handle('', { ...plainKey, return: true })
82
+ : handle(part, plainKey);
83
+ if (acted) break;
84
+ }
85
+ return;
86
+ }
70
87
  handle(input, key);
71
88
  },
72
89
  { isActive: enabled },
@@ -23,7 +23,7 @@ import type {
23
23
  IExecutionDetailPage,
24
24
  IExecutionWorkspaceSnapshot,
25
25
  } from '@robota-sdk/agent-interface-execution';
26
- import type { IToolState } from '@robota-sdk/agent-interface-session';
26
+ import type { ISessionListingEntry, IToolState } from '@robota-sdk/agent-interface-session';
27
27
 
28
28
  export interface ITuiChannelState {
29
29
  uiEventPort: ITuiSessionUiEventPort;
@@ -49,6 +49,14 @@ export interface ITuiChannelState {
49
49
  /** CMD-004: the unified action awaiting a user answer, or null. */
50
50
  pendingUserAction: IActionRequest | null;
51
51
  contextState: { percentage: number; usedTokens: number; maxTokens: number };
52
+ /** The host's sessions for the picker, when the channel's host keeps them. */
53
+ hostSessions: readonly ISessionListingEntry[] | undefined;
54
+ /** Changes when the transcript starts over for another session under this channel. */
55
+ transcriptGeneration: number;
56
+ /** This terminal only observes the session. */
57
+ readOnly: boolean;
58
+ /** This terminal is attached to a session a host runs; leaving only detaches it. */
59
+ attached: boolean;
52
60
  handleSubmit: (input: string) => Promise<void>;
53
61
  handleAbort: () => void;
54
62
  handleCancelQueue: () => void;
@@ -115,6 +123,10 @@ export function useTuiChannel(channel: ITuiAppChannelPort): ITuiChannelState {
115
123
  permissionRequest: snapshot.permissionRequest,
116
124
  pendingUserAction: snapshot.pendingUserAction,
117
125
  contextState: snapshot.contextState,
126
+ hostSessions: snapshot.hostSessions,
127
+ transcriptGeneration: snapshot.transcriptGeneration ?? 0,
128
+ readOnly: snapshot.readOnly === true,
129
+ attached: snapshot.attached === true,
118
130
  handleSubmit: (input) => channel.handleInput(input),
119
131
  handleAbort: () => channel.abort(),
120
132
  handleCancelQueue: () => channel.cancelQueue(),
package/src/index.ts CHANGED
@@ -1,9 +1,23 @@
1
- export { renderApp } from './render.js';
1
+ export { renderApp, renderAttachedApp } from './render.js';
2
2
  export { renderSupervisedSessionView } from './SupervisedSessionView.js';
3
- export type { ISupervisedViewRow, ISupervisedSessionViewProps } from './SupervisedSessionView.js';
3
+ export type {
4
+ IAttachedSessionConnection,
5
+ TAttachedSessionEnd,
6
+ } from './attached-session-connection.js';
7
+ export type {
8
+ ISupervisedAttachRequest,
9
+ ISupervisedViewRow,
10
+ ISupervisedSessionViewProps,
11
+ TSupervisedViewExit,
12
+ } from './SupervisedSessionView.js';
4
13
  export { TuiInteractionChannel } from './TuiInteractionChannel.js';
5
14
  export type { ITuiInteractionChannelOptions } from './TuiInteractionChannel.js';
6
- export type { IRenderOptions } from './render.js';
15
+ export type {
16
+ ITuiClientCommand,
17
+ ITuiClientCommands,
18
+ TTuiClientCommandHost,
19
+ } from './wire-tui-client-commands.js';
20
+ export type { IRenderAttachedAppOptions, IRenderOptions } from './render.js';
7
21
  export type { ITerminalCapabilityOverrides } from './terminal-capabilities-context.js';
8
22
  export type { TScreenReaderChannel } from './screen-reader-announcement.js';
9
23
  export type { IScreenReaderPacingOverrides } from './screen-reader-pacing.js';