@robota-sdk/agent-ui-terminal 3.0.0-beta.81 → 3.0.0-beta.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +858 -0
- package/README.md +5 -1
- package/dist/node/index.d.ts +189 -54
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +21 -21
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -21
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +50 -17
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +479 -157
- package/src/TuiInteractionChannel.ts +8 -53
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
- package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
- package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
- package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
- package/src/__tests__/palette-consistency.test.ts +0 -1
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
- package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
- package/src/__tests__/render-channel-options.test.ts +6 -6
- package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
- package/src/__tests__/screen-reader-menus.test.tsx +1 -1
- package/src/__tests__/session-picker.test.tsx +93 -0
- package/src/__tests__/status-bar.test.tsx +8 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
- package/src/__tests__/wire-tui-channel.test.ts +1445 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-view-model.ts +6 -2
- package/src/attached-session-connection.ts +14 -0
- package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
- package/src/hooks/useAppController.ts +6 -2
- package/src/hooks/useAppInputBindings.ts +20 -2
- package/src/hooks/useAppInteractionState.ts +3 -0
- package/src/hooks/useAppOverlays.ts +20 -4
- package/src/hooks/useNumberedSelection.ts +19 -2
- package/src/hooks/useTuiChannel.ts +13 -1
- package/src/index.ts +17 -3
- package/src/render.tsx +218 -27
- package/src/short-session-id.ts +9 -0
- package/src/slash-command-input.ts +11 -0
- package/src/tui-app-channel-port.ts +29 -1
- package/src/tui-channel-options.ts +11 -2
- package/src/tui-session-event-projector.ts +0 -2
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +17 -3
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +962 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
- package/dist/node/index.cjs +0 -32
- package/dist/node/index.d.cts +0 -1066
- package/dist/node/index.d.cts.map +0 -1
|
@@ -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
|
+
});
|
package/src/app-view-model.ts
CHANGED
|
@@ -21,13 +21,15 @@ import type {
|
|
|
21
21
|
IExecutionWorkspaceSnapshot,
|
|
22
22
|
} from '@robota-sdk/agent-interface-execution';
|
|
23
23
|
import type {
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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
|
|
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 } =
|
|
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]):
|
|
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 {
|
|
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 {
|
|
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';
|