@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,142 @@
1
+ /**
2
+ * #3189: the commands that belong to the terminal the user sits at (`/shell`, `/editor`, `/theme`,
3
+ * `/keybindings`). A terminal attached to a workspace daemon runs these in its own process — on its
4
+ * own terminal, in its own working directory — and sends nothing to the daemon.
5
+ *
6
+ * The attached terminal routes by the set it is given here, never by the daemon's catalog, so an
7
+ * older daemon or a catalog that arrives late cannot send one of these commands to the daemon.
8
+ *
9
+ * The in-process session applies a command's host actions and emits its UI intents; here the
10
+ * terminal does both itself. The one host action these commands produce, the appearance settings
11
+ * patch, is written through the writer the composition root gives. Anything else is refused by name
12
+ * rather than skipped, because a command that asked for an action nobody performed would otherwise
13
+ * report success.
14
+ */
15
+ import { SessionTerminalHandoffGate, isAppearanceSettingsPatch } from '@robota-sdk/agent-framework';
16
+
17
+ import type {
18
+ ICommandHostTerminalHandoff,
19
+ ICommandHostWorkspace,
20
+ } from '@robota-sdk/agent-framework';
21
+ import type {
22
+ ICommandResult,
23
+ TAppearanceSettingsPatch,
24
+ TCommandUiIntent,
25
+ } from '@robota-sdk/agent-interface-command';
26
+ import type { ITerminalHandoff } from '@robota-sdk/agent-interface-session';
27
+
28
+ /** What a client-run command may use: this terminal's handoff and working directory. */
29
+ export type TTuiClientCommandHost = ICommandHostTerminalHandoff & ICommandHostWorkspace;
30
+
31
+ /**
32
+ * A command this terminal runs itself. Declared here rather than imported: the command package's
33
+ * `createTerminalClientCommands` produces objects of this shape, and this package does not depend on
34
+ * the command package.
35
+ */
36
+ export interface ITuiClientCommand {
37
+ readonly name: string;
38
+ execute(host: TTuiClientCommandHost, args: string): ICommandResult | Promise<ICommandResult>;
39
+ }
40
+
41
+ /** The client-run commands, and how this terminal writes what they change. */
42
+ export interface ITuiClientCommands {
43
+ readonly commands: readonly ITuiClientCommand[];
44
+ /** Writes the appearance keys of the user's settings; resolves when written. */
45
+ writeAppearanceSettings(patch: TAppearanceSettingsPatch): void | Promise<void>;
46
+ }
47
+
48
+ /** A client command's answer: the result to show, and the screens it asked this terminal to open. */
49
+ export interface ITuiClientCommandOutcome {
50
+ /** Host actions and UI intents are consumed: this carries only message, success and data. */
51
+ readonly result: ICommandResult;
52
+ /** Empty unless the command succeeded and every host action was applied. */
53
+ readonly uiIntents: readonly TCommandUiIntent[];
54
+ }
55
+
56
+ const APPEARANCE_PATCH = 'appearance-settings-patch';
57
+
58
+ function errorMessage(error: unknown): string {
59
+ return error instanceof Error ? error.message : String(error);
60
+ }
61
+
62
+ function refused(message: string): ITuiClientCommandOutcome {
63
+ return { result: { success: false, message }, uiIntents: [] };
64
+ }
65
+
66
+ /** The client command named `name`, or undefined when there is none or no set was given. */
67
+ export function findTuiClientCommand(
68
+ commands: ITuiClientCommands | undefined,
69
+ name: string,
70
+ ): ITuiClientCommand | undefined {
71
+ return commands?.commands.find((command) => command.name === name);
72
+ }
73
+
74
+ /**
75
+ * Runs a client command and applies what it asks for on this terminal.
76
+ *
77
+ * A failed result passes through unchanged: its actions were never requested to run. Every host
78
+ * action is checked before any is written, so a result that carries an invalid patch or an action
79
+ * this terminal cannot perform writes nothing and becomes an explicit failure. A command that throws
80
+ * rejects, as it does in the in-process session.
81
+ */
82
+ export async function runTuiClientCommand(
83
+ command: ITuiClientCommand,
84
+ args: string,
85
+ host: TTuiClientCommandHost,
86
+ writers: Pick<ITuiClientCommands, 'writeAppearanceSettings'>,
87
+ ): Promise<ITuiClientCommandOutcome> {
88
+ const result = await command.execute(host, args);
89
+ if (!result.success) return { result, uiIntents: [] };
90
+
91
+ const patches: TAppearanceSettingsPatch[] = [];
92
+ for (const action of result.hostActions ?? []) {
93
+ if (action.type !== APPEARANCE_PATCH) {
94
+ return refused(`Cannot apply '${action.type}' on this terminal.`);
95
+ }
96
+ if (!isAppearanceSettingsPatch(action.patch)) {
97
+ return refused(`Failed to apply '${APPEARANCE_PATCH}': invalid appearance settings patch`);
98
+ }
99
+ patches.push(action.patch);
100
+ }
101
+
102
+ for (const patch of patches) {
103
+ try {
104
+ await writers.writeAppearanceSettings(patch);
105
+ } catch (error) {
106
+ return refused(`Failed to apply '${APPEARANCE_PATCH}': ${errorMessage(error)}`);
107
+ }
108
+ }
109
+
110
+ return {
111
+ result: {
112
+ message: result.message,
113
+ success: result.success,
114
+ ...(result.data !== undefined ? { data: result.data } : {}),
115
+ },
116
+ uiIntents: result.uiIntents ?? [],
117
+ };
118
+ }
119
+
120
+ /**
121
+ * The host a client command runs against: this terminal's own handoff, behind the same gate the
122
+ * in-process session puts in front of it (one handoff at a time, and a refusal instead of a hang
123
+ * when there is no interactive terminal). Build it once per attached terminal, so that exclusivity
124
+ * spans every command it runs.
125
+ *
126
+ * Without a handoff, `canHandoffTerminal()` is false and `/shell` and `/editor` take their refusal
127
+ * path. Commands run here were typed by the user at this terminal, so the invocation source is
128
+ * always `'user'`.
129
+ */
130
+ export function createTuiClientCommandHost(options: {
131
+ readonly terminalHandoff: ITerminalHandoff | undefined;
132
+ readonly cwd: string;
133
+ }): TTuiClientCommandHost {
134
+ const gate = new SessionTerminalHandoffGate(options.terminalHandoff);
135
+ const { cwd } = options;
136
+ return {
137
+ canHandoffTerminal: () => gate.canHandoffTerminal(),
138
+ runWithTerminal: (fn) => gate.runWithTerminal(fn),
139
+ getCwd: () => cwd,
140
+ getCommandInvocationSource: () => 'user',
141
+ };
142
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Pure projections from session-protocol frames into what the TUI renders, for a terminal attached to
3
+ * a session over the wire. Kept apart from the channel so each is testable without a connection.
4
+ */
5
+
6
+ import { OWNER_DRIVER_ID } from '@robota-sdk/agent-interface-session';
7
+
8
+ import type { IHistoryEntry } from '@robota-sdk/agent-core';
9
+ import type {
10
+ ICommand,
11
+ ICommandListEntry,
12
+ ICommandSkillListEntry,
13
+ ICommandSubcommandEntry,
14
+ } from '@robota-sdk/agent-interface-command';
15
+ import type { IWireHistoryEntry } from '@robota-sdk/agent-transport/client';
16
+
17
+ /**
18
+ * The transcript names a prompt by the driver that sent it, and "You" for the operator. Over the wire
19
+ * this terminal is a driver the host named, so its own prompts are shown as the operator's.
20
+ */
21
+ export function displayDriverId(
22
+ driverId: string | undefined,
23
+ ownDriverId: string | undefined,
24
+ ): string | undefined {
25
+ return driverId !== undefined && driverId === ownDriverId ? OWNER_DRIVER_ID : driverId;
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ /**
33
+ * Who this terminal is on the wire: the driver id the host gave it, and when it attached. The host
34
+ * numbers the terminals attached to it from one each time it starts, and a prompt keeps its driver id
35
+ * in the stored history, so an entry from before this terminal attached may carry the same id for
36
+ * another terminal. Only what this terminal could have sent is shown as the user's.
37
+ */
38
+ export interface IOwnDriver {
39
+ readonly driverId: string;
40
+ /** Milliseconds since the epoch. */
41
+ readonly since: number;
42
+ }
43
+
44
+ /**
45
+ * A chat entry's `data` is the message itself, whose own `timestamp` also crossed as a string. Any
46
+ * ISO string is accepted: a resumed session sends strings for what was a `Date` in memory.
47
+ */
48
+ function reviveChatMessage(data: unknown, ownDriverId: string | undefined): unknown {
49
+ if (!isRecord(data)) return data;
50
+ let revived = data;
51
+ if (typeof data['timestamp'] === 'string') {
52
+ revived = { ...revived, timestamp: new Date(data['timestamp']) };
53
+ }
54
+ const metadata = data['metadata'];
55
+ if (ownDriverId !== undefined && isRecord(metadata) && metadata['driverId'] === ownDriverId) {
56
+ revived = { ...revived, metadata: { ...metadata, driverId: OWNER_DRIVER_ID } };
57
+ }
58
+ return revived;
59
+ }
60
+
61
+ /**
62
+ * The session's history as the TUI holds it: timestamps back to `Date`s. Only a chat entry's `data`
63
+ * is a message; an event entry's is the event as recorded, and is left as it came.
64
+ */
65
+ export function toHistoryEntries(
66
+ entries: readonly IWireHistoryEntry[],
67
+ own: IOwnDriver | undefined,
68
+ ): IHistoryEntry[] {
69
+ return entries.map((entry) => {
70
+ const timestamp = new Date(entry.timestamp);
71
+ if (entry.category !== 'chat' || entry.data === undefined) return { ...entry, timestamp };
72
+ const ownDriverId =
73
+ own !== undefined && timestamp.getTime() >= own.since ? own.driverId : undefined;
74
+ return { ...entry, timestamp, data: reviveChatMessage(entry.data, ownDriverId) };
75
+ });
76
+ }
77
+
78
+ /** A subcommand as the `/` menu offers it after its command's name. */
79
+ function toSubcommand(subcommand: ICommandSubcommandEntry): ICommand {
80
+ return {
81
+ name: subcommand.name,
82
+ description: subcommand.description,
83
+ source: 'builtin',
84
+ ...(subcommand.displayName !== undefined ? { displayName: subcommand.displayName } : {}),
85
+ ...(subcommand.argumentHint !== undefined ? { argumentHint: subcommand.argumentHint } : {}),
86
+ };
87
+ }
88
+
89
+ /** What the `/` menu offers: the host's commands, then the skills a user may invoke. */
90
+ export function toCommandCatalog(
91
+ commands: readonly ICommandListEntry[],
92
+ skills: readonly ICommandSkillListEntry[],
93
+ ): ICommand[] {
94
+ return [
95
+ ...commands.map((command): ICommand => ({
96
+ name: command.name,
97
+ description: command.description,
98
+ source: 'builtin',
99
+ modelInvocable: command.modelInvocable,
100
+ ...(command.displayName !== undefined ? { displayName: command.displayName } : {}),
101
+ ...(command.example !== undefined ? { example: command.example } : {}),
102
+ ...(command.argumentHint !== undefined ? { argumentHint: command.argumentHint } : {}),
103
+ ...(command.subcommands !== undefined && command.subcommands.length > 0
104
+ ? { subcommands: command.subcommands.map(toSubcommand) }
105
+ : {}),
106
+ })),
107
+ ...skills
108
+ .filter((skill) => skill.userInvocable)
109
+ .map((skill): ICommand => ({
110
+ name: skill.name,
111
+ description: skill.description,
112
+ source: skill.source,
113
+ modelInvocable: skill.modelInvocable,
114
+ userInvocable: skill.userInvocable,
115
+ ...(skill.argumentHint !== undefined ? { argumentHint: skill.argumentHint } : {}),
116
+ ...(skill.context !== undefined ? { context: skill.context } : {}),
117
+ ...(skill.agent !== undefined ? { agent: skill.agent } : {}),
118
+ })),
119
+ ];
120
+ }
121
+
122
+ /** A command's subcommands, found by name as the in-process command registry finds them. */
123
+ export function findSubcommands(catalog: readonly ICommand[], commandName: string): ICommand[] {
124
+ const lower = commandName.toLowerCase();
125
+ const command = catalog.find(
126
+ (entry) => entry.name.toLowerCase() === lower && entry.subcommands !== undefined,
127
+ );
128
+ return command?.subcommands ?? [];
129
+ }
130
+
131
+ /** The same prefix match the in-process command registry applies. */
132
+ export function filterCommandCatalog(catalog: readonly ICommand[], filter?: string): ICommand[] {
133
+ if (!filter) return [...catalog];
134
+ const lower = filter.toLowerCase();
135
+ return catalog.filter((command) => command.name.toLowerCase().startsWith(lower));
136
+ }