@robota-sdk/agent-transport 3.0.0-beta.69 → 3.0.0-beta.71

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/dist/node/headless/index.cjs +1 -1
  2. package/dist/node/headless/index.d.ts +2 -2
  3. package/dist/node/headless/index.js +1 -1
  4. package/dist/node/headless-C6tj35h3.js +15 -0
  5. package/dist/node/headless-C6tj35h3.js.map +1 -0
  6. package/dist/node/headless-DCtHvyVf.cjs +14 -0
  7. package/dist/node/http/index.d.ts +1 -1
  8. package/dist/node/index-27HV5PJB.d.ts +68 -0
  9. package/dist/node/index-27HV5PJB.d.ts.map +1 -0
  10. package/dist/node/index-BRchlFBE.d.ts +68 -0
  11. package/dist/node/index-BRchlFBE.d.ts.map +1 -0
  12. package/dist/node/{index-C7DvsmEg.d.ts → index-BRgV_MPB.d.ts} +2 -2
  13. package/dist/node/{index-C7DvsmEg.d.ts.map → index-BRgV_MPB.d.ts.map} +1 -1
  14. package/dist/node/{index-D-aT_t_N.d.ts → index-BVNhOeeU.d.ts} +3 -2
  15. package/dist/node/{index-D-aT_t_N.d.ts.map → index-BVNhOeeU.d.ts.map} +1 -1
  16. package/dist/node/{index-yvGShbDx.d.ts → index-COWvtBa2.d.ts} +2 -2
  17. package/dist/node/{index-yvGShbDx.d.ts.map → index-COWvtBa2.d.ts.map} +1 -1
  18. package/dist/node/{index-ioN9mYAD.d.ts → index-TMAlNHuM.d.ts} +5 -4
  19. package/dist/node/{index-ioN9mYAD.d.ts.map → index-TMAlNHuM.d.ts.map} +1 -1
  20. package/dist/node/{index-DOA2KIYt.d.ts → index-nBlMTFkZ.d.ts} +2 -2
  21. package/dist/node/{index-DOA2KIYt.d.ts.map → index-nBlMTFkZ.d.ts.map} +1 -1
  22. package/dist/node/index.cjs +1 -1
  23. package/dist/node/index.d.ts +7 -7
  24. package/dist/node/index.js +1 -1
  25. package/dist/node/index.js.map +1 -1
  26. package/dist/node/mcp/index.d.ts +1 -1
  27. package/dist/node/tui/index.cjs +1 -1
  28. package/dist/node/tui/index.d.ts +2 -2
  29. package/dist/node/tui/index.js +1 -1
  30. package/dist/node/tui-Bl-bm9iA.js +25 -0
  31. package/dist/node/tui-Bl-bm9iA.js.map +1 -0
  32. package/dist/node/tui-DULGN7sr.cjs +24 -0
  33. package/dist/node/ws/index.d.ts +1 -1
  34. package/package.json +6 -6
  35. package/src/headless/HeadlessInteractionChannel.ts +84 -0
  36. package/src/headless/index.ts +2 -0
  37. package/src/tui/App.tsx +38 -60
  38. package/src/tui/InputArea.tsx +3 -59
  39. package/src/tui/StatusBar.tsx +1 -1
  40. package/src/tui/TuiInteractionChannel.ts +461 -0
  41. package/src/tui/__tests__/TuiInteractionChannel.display-contract.test.ts +239 -0
  42. package/src/tui/__tests__/TuiInteractionChannel.lifecycle.test.ts +294 -0
  43. package/src/tui/__tests__/TuiInteractionChannel.requestAction.test.ts +124 -0
  44. package/src/tui/__tests__/compact-event-bridge.test.ts +1 -1
  45. package/src/tui/__tests__/input-area-flow.test.ts +5 -12
  46. package/src/tui/flows/input-area-flow.ts +10 -15
  47. package/src/tui/hooks/use-interactive-session-init.ts +37 -2
  48. package/src/tui/hooks/useSlashRouting.ts +1 -1
  49. package/src/tui/hooks/useTuiChannel.ts +95 -0
  50. package/src/tui/index.ts +2 -1
  51. package/src/tui/interactions/__tests__/CommandConfirm.test.tsx +124 -0
  52. package/src/tui/interactions/__tests__/CommandPicker.test.tsx +138 -0
  53. package/src/tui/render.tsx +43 -1
  54. package/src/tui/tui-state-manager.ts +2 -1
  55. package/src/tui/tui-transport.ts +1 -1
  56. package/dist/node/headless-C-Ezlo9U.js +0 -15
  57. package/dist/node/headless-C-Ezlo9U.js.map +0 -1
  58. package/dist/node/headless-Cv-igy49.cjs +0 -14
  59. package/dist/node/index-CP7kaYMg.d.ts +0 -41
  60. package/dist/node/index-CP7kaYMg.d.ts.map +0 -1
  61. package/dist/node/index-Gby9H4q2.d.ts +0 -41
  62. package/dist/node/index-Gby9H4q2.d.ts.map +0 -1
  63. package/dist/node/tui-87G6pg3z.js +0 -25
  64. package/dist/node/tui-87G6pg3z.js.map +0 -1
  65. package/dist/node/tui-BAtwGilM.cjs +0 -24
  66. package/src/tui/command-interaction-registry.ts +0 -66
  67. package/src/tui/hooks/useInteractiveSession.ts +0 -299
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Integration tests for TuiInteractionChannel lifecycle:
3
+ * session event wiring, handleInput roundtrip, onChange propagation.
4
+ *
5
+ * No Ink rendering, no PTY — pure TypeScript.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+
9
+ vi.mock('@robota-sdk/agent-framework', async () => {
10
+ const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
11
+ '@robota-sdk/agent-framework',
12
+ );
13
+ return {
14
+ ...actual,
15
+ InteractiveSession: vi.fn().mockImplementation(() => {
16
+ const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
17
+ return {
18
+ getFullHistory: vi.fn().mockReturnValue([]),
19
+ setName: vi.fn(),
20
+ getName: vi.fn().mockReturnValue(undefined),
21
+ getPermissionMode: vi.fn().mockReturnValue('default'),
22
+ isInitialized: false,
23
+ on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
24
+ if (!handlers.has(event)) handlers.set(event, []);
25
+ handlers.get(event)!.push(handler);
26
+ }),
27
+ off: vi.fn(),
28
+ emit: (event: string, ...args: unknown[]) => {
29
+ (handlers.get(event) ?? []).forEach((h) => h(...args));
30
+ },
31
+ submit: vi.fn().mockResolvedValue(undefined),
32
+ executeCommand: vi.fn().mockResolvedValue(null),
33
+ getPendingPrompt: vi.fn().mockReturnValue(null),
34
+ abort: vi.fn(),
35
+ cancelQueue: vi.fn(),
36
+ getContextState: vi.fn().mockReturnValue({
37
+ usedPercentage: 0,
38
+ usedTokens: 0,
39
+ maxTokens: 100_000,
40
+ }),
41
+ getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
42
+ shutdown: vi.fn().mockResolvedValue(undefined),
43
+ sendAgentJob: vi.fn().mockResolvedValue(undefined),
44
+ readExecutionWorkspaceDetail: vi.fn().mockResolvedValue({}),
45
+ };
46
+ }),
47
+ CommandRegistry: vi.fn().mockImplementation(() => ({
48
+ addModule: vi.fn(),
49
+ })),
50
+ };
51
+ });
52
+
53
+ import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
54
+
55
+ import type { IAIProvider } from '@robota-sdk/agent-core';
56
+ import type { IExecutionResult, IInteractiveSession } from '@robota-sdk/agent-framework';
57
+ import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
58
+
59
+ // ── Helpers ───────────────────────────────────────────────────────────────────
60
+
61
+ type MockSession = {
62
+ getFullHistory: ReturnType<typeof vi.fn>;
63
+ submit: ReturnType<typeof vi.fn>;
64
+ executeCommand: ReturnType<typeof vi.fn>;
65
+ on: ReturnType<typeof vi.fn>;
66
+ emit: (event: string, ...args: unknown[]) => void;
67
+ };
68
+
69
+ function getMockSession(channel: TuiInteractionChannel): MockSession {
70
+ return (channel as unknown as { interactiveSession: MockSession }).interactiveSession;
71
+ }
72
+
73
+ function emitSessionEvent(channel: TuiInteractionChannel, event: string, ...args: unknown[]): void {
74
+ getMockSession(channel).emit(event, ...args);
75
+ }
76
+
77
+ function makeMockTransportRegistry(): {
78
+ registry: ITransportRegistryView<IInteractiveSession>;
79
+ startAll: ReturnType<typeof vi.fn>;
80
+ stopAll: ReturnType<typeof vi.fn>;
81
+ } {
82
+ const startAll = vi.fn().mockResolvedValue(undefined);
83
+ const stopAll = vi.fn().mockResolvedValue(undefined);
84
+ return {
85
+ registry: { startAll, stopAll } as unknown as ITransportRegistryView<IInteractiveSession>,
86
+ startAll,
87
+ stopAll,
88
+ };
89
+ }
90
+
91
+ function makeChannel(opts?: {
92
+ transportRegistry?: ITransportRegistryView<IInteractiveSession>;
93
+ }): TuiInteractionChannel {
94
+ return new TuiInteractionChannel({
95
+ cwd: '/tmp/test',
96
+ provider: {} as IAIProvider,
97
+ ...opts,
98
+ });
99
+ }
100
+
101
+ const MOCK_RESULT = {
102
+ contextState: { usedPercentage: 10, usedTokens: 1_000, maxTokens: 100_000 },
103
+ response: 'Hello!',
104
+ } as unknown as IExecutionResult;
105
+
106
+ const MOCK_TOOL = {
107
+ toolName: 'bash',
108
+ isRunning: true,
109
+ input: '{}',
110
+ startTime: Date.now(),
111
+ } as unknown as Parameters<
112
+ InstanceType<typeof TuiInteractionChannel>['stateManager']['onToolStart']
113
+ >[0];
114
+
115
+ beforeEach(() => {
116
+ vi.useFakeTimers();
117
+ });
118
+
119
+ afterEach(() => {
120
+ vi.useRealTimers();
121
+ });
122
+
123
+ // ── Group A: channel.start() / channel.stop() lifecycle ───────────────────────
124
+
125
+ describe('Group A — channel.start() / channel.stop() lifecycle', () => {
126
+ it('A1: text_delta after start() updates stateManager.streamingText', async () => {
127
+ const channel = makeChannel();
128
+ await channel.start();
129
+
130
+ emitSessionEvent(channel, 'text_delta', 'Hello!');
131
+
132
+ expect(channel.stateManager.streamingText).toBe('Hello!');
133
+ await channel.stop();
134
+ });
135
+
136
+ it('A2: complete after start() clears streaming state and updates contextState', async () => {
137
+ const channel = makeChannel();
138
+ await channel.start();
139
+
140
+ emitSessionEvent(channel, 'text_delta', 'streaming...');
141
+ emitSessionEvent(channel, 'complete', MOCK_RESULT);
142
+
143
+ expect(channel.stateManager.streamingText).toBe('');
144
+ expect(channel.stateManager.contextState.percentage).toBe(10);
145
+ expect(channel.stateManager.contextState.usedTokens).toBe(1_000);
146
+ await channel.stop();
147
+ });
148
+
149
+ it('A3: tool_start after start() adds entry to stateManager.activeTools', async () => {
150
+ const channel = makeChannel();
151
+ await channel.start();
152
+
153
+ emitSessionEvent(channel, 'tool_start', MOCK_TOOL);
154
+
155
+ expect(channel.stateManager.activeTools).toHaveLength(1);
156
+ expect(channel.stateManager.activeTools[0]).toMatchObject({ toolName: 'bash' });
157
+ await channel.stop();
158
+ });
159
+
160
+ it('A4: error after start() clears stateManager.streamingText', async () => {
161
+ const channel = makeChannel();
162
+ await channel.start();
163
+
164
+ emitSessionEvent(channel, 'text_delta', 'partial...');
165
+ emitSessionEvent(channel, 'error');
166
+
167
+ expect(channel.stateManager.streamingText).toBe('');
168
+ await channel.stop();
169
+ });
170
+
171
+ it('A5: calling start() twice does not duplicate subscriptions', async () => {
172
+ const channel = makeChannel();
173
+ await channel.start();
174
+ await channel.start(); // second call is a no-op (sessionStarted guard)
175
+
176
+ emitSessionEvent(channel, 'text_delta', 'hi');
177
+
178
+ expect(channel.stateManager.streamingText).toBe('hi'); // not 'hihi'
179
+ await channel.stop();
180
+ });
181
+
182
+ it('A6: stop() calls transportRegistry.stopAll exactly once', async () => {
183
+ const { registry, stopAll } = makeMockTransportRegistry();
184
+ const channel = makeChannel({ transportRegistry: registry });
185
+ await channel.start();
186
+ await channel.stop();
187
+
188
+ expect(stopAll).toHaveBeenCalledOnce();
189
+ });
190
+ });
191
+
192
+ // ── Group B: handleInput() AI-response roundtrip ──────────────────────────────
193
+
194
+ describe('Group B — handleInput() roundtrip', () => {
195
+ it('B1: handleInput("hello") calls session.submit with "hello"', async () => {
196
+ const channel = makeChannel();
197
+ await channel.start();
198
+
199
+ await channel.handleInput('hello');
200
+
201
+ const mockSession = getMockSession(channel);
202
+ expect(mockSession.submit).toHaveBeenCalledWith('hello');
203
+ await channel.stop();
204
+ });
205
+
206
+ it('B2: text_delta + complete syncs history to stateManager', async () => {
207
+ const channel = makeChannel();
208
+ const mockSession = getMockSession(channel);
209
+ const historyEntry = {
210
+ role: 'assistant',
211
+ content: [{ type: 'text', text: 'Hi!' }],
212
+ timestamp: Date.now(),
213
+ };
214
+ mockSession.getFullHistory.mockReturnValue([historyEntry]);
215
+ await channel.start();
216
+
217
+ await channel.handleInput('hello');
218
+ emitSessionEvent(channel, 'text_delta', 'Hi!');
219
+ expect(channel.stateManager.streamingText).toBe('Hi!');
220
+
221
+ emitSessionEvent(channel, 'complete', MOCK_RESULT);
222
+ expect(channel.stateManager.streamingText).toBe('');
223
+ expect(channel.stateManager.history).toHaveLength(1);
224
+
225
+ await channel.stop();
226
+ });
227
+
228
+ it('B3: handleInput("/help") calls executeCommand, not session.submit', async () => {
229
+ const channel = makeChannel();
230
+ await channel.start();
231
+
232
+ await channel.handleInput('/help');
233
+
234
+ const mockSession = getMockSession(channel);
235
+ expect(mockSession.submit).not.toHaveBeenCalled();
236
+ expect(mockSession.executeCommand).toHaveBeenCalledWith('help', '');
237
+ await channel.stop();
238
+ });
239
+
240
+ it('B4: handleInput("hello") triggers channel.onChange at least once', async () => {
241
+ const channel = makeChannel();
242
+ const onChange = vi.fn();
243
+ channel.onChange = onChange;
244
+ await channel.start();
245
+ onChange.mockClear();
246
+
247
+ await channel.handleInput('hello');
248
+ emitSessionEvent(channel, 'text_delta', 'hey');
249
+
250
+ expect(onChange).toHaveBeenCalled();
251
+ await channel.stop();
252
+ });
253
+ });
254
+
255
+ // ── Group C: onChange propagation invariant ───────────────────────────────────
256
+
257
+ describe('Group C — onChange propagation invariant', () => {
258
+ it('C1: session event after start() causes channel.onChange to fire', async () => {
259
+ const channel = makeChannel();
260
+ const onChange = vi.fn();
261
+ channel.onChange = onChange;
262
+ await channel.start();
263
+ onChange.mockClear();
264
+
265
+ // tool_start calls notify() directly (not debounced), so onChange fires immediately
266
+ emitSessionEvent(channel, 'tool_start', MOCK_TOOL);
267
+
268
+ expect(onChange).toHaveBeenCalled();
269
+ await channel.stop();
270
+ });
271
+
272
+ it('C2: channel.onChange does not fire for events before start()', () => {
273
+ const channel = makeChannel();
274
+ const onChange = vi.fn();
275
+ channel.onChange = onChange;
276
+ // Do NOT call channel.start() — handlers not registered yet
277
+ emitSessionEvent(channel, 'text_delta', 'hello'); // no-op: no handlers
278
+
279
+ expect(onChange).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it('C3: channel.onChange does not fire for events after stop()', async () => {
283
+ const channel = makeChannel();
284
+ const onChange = vi.fn();
285
+ channel.onChange = onChange;
286
+ await channel.start();
287
+ await channel.stop(); // sets this.onChange = null
288
+ onChange.mockClear();
289
+
290
+ emitSessionEvent(channel, 'text_delta', 'hello');
291
+
292
+ expect(onChange).not.toHaveBeenCalled();
293
+ });
294
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Unit tests for TuiInteractionChannel.requestAction() promise protocol.
3
+ *
4
+ * Tests the queue-based action resolution mechanism in isolation —
5
+ * no Ink rendering, no InteractiveSession, no real provider required.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+
9
+ vi.mock('@robota-sdk/agent-framework', async () => {
10
+ const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
11
+ '@robota-sdk/agent-framework',
12
+ );
13
+ return {
14
+ ...actual,
15
+ InteractiveSession: vi.fn().mockImplementation(() => ({
16
+ getFullHistory: vi.fn().mockReturnValue([]),
17
+ setName: vi.fn(),
18
+ getSessionId: vi.fn().mockReturnValue('test-id'),
19
+ isInitialized: false,
20
+ on: vi.fn(),
21
+ off: vi.fn(),
22
+ })),
23
+ CommandRegistry: vi.fn().mockImplementation(() => ({
24
+ addModule: vi.fn(),
25
+ })),
26
+ };
27
+ });
28
+
29
+ import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
30
+
31
+ import type { IAIProvider } from '@robota-sdk/agent-core';
32
+ import type { IActionRequest } from '@robota-sdk/agent-framework';
33
+
34
+ function makeChannel(): TuiInteractionChannel {
35
+ return new TuiInteractionChannel({
36
+ cwd: '/tmp',
37
+ provider: {} as IAIProvider,
38
+ });
39
+ }
40
+
41
+ const PICK_ACTION: IActionRequest = {
42
+ type: 'pick',
43
+ id: 'mode',
44
+ title: '/mode',
45
+ items: [
46
+ { label: 'plan', value: 'plan' },
47
+ { label: 'default', value: 'default' },
48
+ ],
49
+ };
50
+
51
+ const CONFIRM_ACTION: IActionRequest = {
52
+ type: 'confirm',
53
+ id: 'exit',
54
+ message: 'Exit the session?',
55
+ };
56
+
57
+ describe('TuiInteractionChannel.requestAction', () => {
58
+ let channel: TuiInteractionChannel;
59
+
60
+ beforeEach(() => {
61
+ channel = makeChannel();
62
+ });
63
+
64
+ it('sets pendingAction when requestAction is called', () => {
65
+ void channel.requestAction(PICK_ACTION);
66
+ expect(channel.pendingAction).toMatchObject({ type: 'pick', id: 'mode' });
67
+ });
68
+
69
+ it('resolves pick response when resolveAction is called', async () => {
70
+ const responsePromise = channel.requestAction(PICK_ACTION);
71
+ channel.resolveAction({ type: 'pick', item: { label: 'plan', value: 'plan' } });
72
+ const response = await responsePromise;
73
+ expect(response).toEqual({ type: 'pick', item: { label: 'plan', value: 'plan' } });
74
+ });
75
+
76
+ it('clears pendingAction after resolveAction', async () => {
77
+ const responsePromise = channel.requestAction(PICK_ACTION);
78
+ channel.resolveAction({ type: 'cancelled' });
79
+ await responsePromise;
80
+ expect(channel.pendingAction).toBeNull();
81
+ });
82
+
83
+ it('resolves confirm response when resolveAction is called', async () => {
84
+ const responsePromise = channel.requestAction(CONFIRM_ACTION);
85
+ channel.resolveAction({ type: 'confirm', confirmed: true });
86
+ const response = await responsePromise;
87
+ expect(response).toEqual({ type: 'confirm', confirmed: true });
88
+ });
89
+
90
+ it('resolves cancelled when resolveAction is called with cancelled', async () => {
91
+ const responsePromise = channel.requestAction(PICK_ACTION);
92
+ channel.resolveAction({ type: 'cancelled' });
93
+ const response = await responsePromise;
94
+ expect(response).toEqual({ type: 'cancelled' });
95
+ });
96
+
97
+ it('queues multiple actions and processes them sequentially', async () => {
98
+ const p1 = channel.requestAction(PICK_ACTION);
99
+ const p2 = channel.requestAction(CONFIRM_ACTION);
100
+
101
+ // First action is pending immediately
102
+ expect(channel.pendingAction).toMatchObject({ type: 'pick' });
103
+
104
+ // Resolve first
105
+ channel.resolveAction({ type: 'pick', item: { label: 'plan', value: 'plan' } });
106
+ await p1;
107
+
108
+ // Second action becomes pending after first resolves
109
+ expect(channel.pendingAction).toMatchObject({ type: 'confirm' });
110
+
111
+ // Resolve second
112
+ channel.resolveAction({ type: 'confirm', confirmed: false });
113
+ const r2 = await p2;
114
+ expect(r2).toEqual({ type: 'confirm', confirmed: false });
115
+ expect(channel.pendingAction).toBeNull();
116
+ });
117
+
118
+ it('calls onChange when pendingAction changes', () => {
119
+ const onChange = vi.fn();
120
+ channel.onChange = onChange;
121
+ void channel.requestAction(PICK_ACTION);
122
+ expect(onChange).toHaveBeenCalled();
123
+ });
124
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { createSystemMessage, messageToHistoryEntry } from '@robota-sdk/agent-core';
3
3
  import { TuiStateManager } from '../tui-state-manager.js';
4
- import { applyCompactEventToManager } from '../hooks/useInteractiveSession.js';
4
+ import { applyCompactEventToManager } from '../hooks/useTuiChannel.js';
5
5
 
6
6
  describe('compact event bridge', () => {
7
7
  it('syncs session history so automatic compaction notifications render', () => {
@@ -14,7 +14,6 @@ import {
14
14
  shouldSubmitInput,
15
15
  } from '../flows/input-area-flow.js';
16
16
  import type { ICommand } from '@robota-sdk/agent-framework';
17
- import type { ITuiPickerInteraction } from '../command-interaction.js';
18
17
  import {
19
18
  createAssistantMessage,
20
19
  createSystemMessage,
@@ -67,20 +66,14 @@ describe('input area flow', () => {
67
66
  expect(result).toEqual({ type: 'submit', value: '/help' });
68
67
  });
69
68
 
70
- it('Given interaction declared and no args When enter selects command Then open-interaction is returned', () => {
71
- const result = resolveEnterCommandSelection('/ex', command('exit'), {
72
- onMissingArgs: 'confirm',
73
- });
69
+ it('Given command with no args and no subcommands When enter selects Then submits', () => {
70
+ const result = resolveEnterCommandSelection('/ex', command('exit'));
74
71
 
75
- expect(result).toEqual({ type: 'open-interaction', commandName: 'exit' });
72
+ expect(result).toEqual({ type: 'submit', value: '/exit' });
76
73
  });
77
74
 
78
- it('Given interaction declared but subcommand selected (args present) When enter selects Then submits', () => {
79
- const pickerInteraction: ITuiPickerInteraction = {
80
- onMissingArgs: 'picker',
81
- getItems: () => [],
82
- };
83
- const result = resolveEnterCommandSelection('/mode plan', command('plan'), pickerInteraction);
75
+ it('Given subcommand selected (args present) When enter selects Then submits', () => {
76
+ const result = resolveEnterCommandSelection('/mode plan', command('plan'));
84
77
 
85
78
  expect(result).toEqual({ type: 'submit', value: '/mode plan' });
86
79
  });
@@ -1,6 +1,5 @@
1
- import { parseSlashInput } from '../hooks/useAutocomplete.js';
1
+ import { isSlashCommand, tokeniseSlashCommand } from '@robota-sdk/agent-framework';
2
2
 
3
- import type { ITuiCommandInteraction } from '../command-interaction.js';
4
3
  import type { IHistoryEntry, TUniversalValue } from '@robota-sdk/agent-core';
5
4
  import type { ICommand } from '@robota-sdk/agent-framework';
6
5
 
@@ -19,8 +18,7 @@ export type TPromptHistoryInputAction = 'previous' | 'next';
19
18
 
20
19
  export type TCommandSelectionResult =
21
20
  | { type: 'insert'; value: string; selectedIndex?: number }
22
- | { type: 'submit'; value: string }
23
- | { type: 'open-interaction'; commandName: string };
21
+ | { type: 'submit'; value: string };
24
22
 
25
23
  export interface IPasteLabelChange {
26
24
  value: string;
@@ -144,9 +142,10 @@ export function moveAutocompleteSelection(
144
142
  }
145
143
 
146
144
  export function resolveTabCompletion(value: string, command: ICommand): TCommandSelectionResult {
147
- const parsed = parseSlashInput(value);
148
- if (parsed.parentCommand) {
149
- return { type: 'insert', value: `/${parsed.parentCommand} ${command.name} ` };
145
+ // Subcommand mode: '/parent filter' — space present after command name
146
+ if (isSlashCommand(value) && value.slice(1).includes(' ')) {
147
+ const { name } = tokeniseSlashCommand(value);
148
+ return { type: 'insert', value: `/${name} ${command.name} ` };
150
149
  }
151
150
  if (command.subcommands && command.subcommands.length > 0) {
152
151
  return { type: 'insert', value: `/${command.name} `, selectedIndex: 0 };
@@ -157,15 +156,11 @@ export function resolveTabCompletion(value: string, command: ICommand): TCommand
157
156
  export function resolveEnterCommandSelection(
158
157
  value: string,
159
158
  command: ICommand,
160
- interaction?: ITuiCommandInteraction,
161
159
  ): TCommandSelectionResult {
162
- const parsed = parseSlashInput(value);
163
- if (parsed.parentCommand) {
164
- return { type: 'submit', value: `/${parsed.parentCommand} ${command.name}` };
165
- }
166
- // parentCommand is empty → no args provided beyond the command name itself
167
- if (interaction?.onMissingArgs) {
168
- return { type: 'open-interaction', commandName: command.name };
160
+ // Subcommand mode: '/parent filter' — space present after command name
161
+ if (isSlashCommand(value) && value.slice(1).includes(' ')) {
162
+ const { name } = tokeniseSlashCommand(value);
163
+ return { type: 'submit', value: `/${name} ${command.name}` };
169
164
  }
170
165
  if (command.subcommands && command.subcommands.length > 0) {
171
166
  return { type: 'insert', value: `/${command.name} `, selectedIndex: 0 };
@@ -3,9 +3,44 @@ import { InteractiveSession, CommandRegistry } from '@robota-sdk/agent-framework
3
3
  import { TuiStateManager } from '../tui-state-manager.js';
4
4
  import { CommandEffectQueue, type ICommandEffectQueue } from './command-effect-queue.js';
5
5
 
6
- import type { IInteractiveSessionProps } from './useInteractiveSession.js';
6
+ import type { IAIProvider, TPermissionMode } from '@robota-sdk/agent-core';
7
7
  import type { TToolArgs } from '@robota-sdk/agent-core';
8
- import type { TPermissionResultValue } from '@robota-sdk/agent-framework';
8
+ import type {
9
+ IBackgroundTaskRunner,
10
+ ICommandHostAdapters,
11
+ ICommandModule,
12
+ IInteractiveSession,
13
+ IInteractiveSessionStore,
14
+ TSubagentRunnerFactory,
15
+ TShellExecFn,
16
+ TPermissionResultValue,
17
+ } from '@robota-sdk/agent-framework';
18
+ import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
19
+
20
+ export interface IInteractiveSessionProps {
21
+ cwd: string;
22
+ provider: IAIProvider;
23
+ permissionMode?: TPermissionMode;
24
+ maxTurns?: number;
25
+ sessionStore?: IInteractiveSessionStore;
26
+ resumeSessionId?: string;
27
+ forkSession?: boolean;
28
+ sessionName?: string;
29
+ onAutoNamed?: (name: string) => void;
30
+ backgroundTaskRunners?: IBackgroundTaskRunner[];
31
+ subagentRunnerFactory?: TSubagentRunnerFactory;
32
+ commandModules?: readonly ICommandModule[];
33
+ commandHostAdapters?: ICommandHostAdapters;
34
+ shellExec?: TShellExecFn;
35
+ transportRegistry?: ITransportRegistryView<IInteractiveSession>;
36
+ language?: string;
37
+ reloadPluginCommandSource?: (registry: CommandRegistry) => void;
38
+ agentName?: string;
39
+ systemPrompt?: string;
40
+ appendSystemPrompt?: string;
41
+ allowedTools?: string[];
42
+ deniedTools?: string[];
43
+ }
9
44
 
10
45
  export interface IInitState {
11
46
  interactiveSession: InteractiveSession;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Slash command routing logic for the TUI.
3
- * Extracted from useInteractiveSession for single-responsibility.
3
+ * Extracted for single-responsibility.
4
4
  */
5
5
 
6
6
  import { createSystemMessage, messageToHistoryEntry } from '@robota-sdk/agent-core';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * useTuiChannel — React hook that subscribes to TuiInteractionChannel state changes.
3
+ *
4
+ * Returns the same shape as the former IInteractiveSessionState so that App.tsx
5
+ * changes are minimal.
6
+ */
7
+
8
+ import { useState, useEffect } from 'react';
9
+
10
+ import type { TuiInteractionChannel } from '../TuiInteractionChannel.js';
11
+ import type { ICommandEffectQueue } from './command-effect-queue.js';
12
+ import type { IPermissionRequest } from '../types.js';
13
+ import type { IHistoryEntry, TSessionEndReason } from '@robota-sdk/agent-core';
14
+ import type { InteractiveSession, CommandRegistry } from '@robota-sdk/agent-framework';
15
+ import type {
16
+ IToolState,
17
+ IExecutionWorkspaceSnapshot,
18
+ IExecutionDetailPage,
19
+ } from '@robota-sdk/agent-framework';
20
+
21
+ export interface IInteractiveSessionState {
22
+ interactiveSession: InteractiveSession;
23
+ registry: CommandRegistry;
24
+ commandEffectQueue: ICommandEffectQueue;
25
+ history: IHistoryEntry[];
26
+ addEntry: (entry: IHistoryEntry) => void;
27
+ streamingText: string;
28
+ activeTools: IToolState[];
29
+ isThinking: boolean;
30
+ isAborting: boolean;
31
+ isShuttingDown: boolean;
32
+ pendingPrompt: string | null;
33
+ executionWorkspaceSnapshot: IExecutionWorkspaceSnapshot | null;
34
+ selectedExecutionEntryId?: string;
35
+ permissionRequest: IPermissionRequest | null;
36
+ contextState: { percentage: number; usedTokens: number; maxTokens: number };
37
+ handleSubmit: (input: string) => Promise<void>;
38
+ handleAbort: () => void;
39
+ handleCancelQueue: () => void;
40
+ handleShutdown: (reason?: TSessionEndReason) => Promise<void>;
41
+ selectExecutionWorkspaceEntry: (entryId: string) => void;
42
+ readExecutionWorkspaceDetail: (entryId: string) => Promise<IExecutionDetailPage>;
43
+ }
44
+
45
+ interface IHistoryReadableSession {
46
+ getFullHistory(): IHistoryEntry[];
47
+ }
48
+
49
+ interface IHistorySyncManager {
50
+ syncHistory(entries: IHistoryEntry[]): void;
51
+ }
52
+
53
+ export function applyCompactEventToManager(
54
+ interactiveSession: IHistoryReadableSession,
55
+ manager: IHistorySyncManager,
56
+ ): void {
57
+ manager.syncHistory(interactiveSession.getFullHistory());
58
+ }
59
+
60
+ export function useTuiChannel(channel: TuiInteractionChannel): IInteractiveSessionState {
61
+ const [, forceRender] = useState(0);
62
+
63
+ useEffect(() => {
64
+ channel.onChange = () => forceRender((n) => n + 1);
65
+ return () => {
66
+ channel.onChange = null;
67
+ };
68
+ }, [channel]);
69
+
70
+ const manager = channel.stateManager;
71
+
72
+ return {
73
+ interactiveSession: channel.getSession(),
74
+ registry: channel.getRegistry(),
75
+ commandEffectQueue: channel.getCommandEffectQueue(),
76
+ history: manager.history,
77
+ addEntry: (e) => manager.addEntry(e),
78
+ streamingText: manager.streamingText,
79
+ activeTools: manager.activeTools,
80
+ isThinking: manager.isThinking,
81
+ isAborting: manager.isAborting,
82
+ isShuttingDown: channel.isShuttingDown,
83
+ pendingPrompt: manager.pendingPrompt,
84
+ executionWorkspaceSnapshot: manager.executionWorkspaceSnapshot,
85
+ selectedExecutionEntryId: manager.selectedExecutionEntryId,
86
+ permissionRequest: channel.permissionRequest,
87
+ contextState: manager.contextState,
88
+ handleSubmit: (input) => channel.handleInput(input),
89
+ handleAbort: () => channel.abort(),
90
+ handleCancelQueue: () => channel.cancelQueue(),
91
+ handleShutdown: (reason) => channel.shutdown({ reason }),
92
+ selectExecutionWorkspaceEntry: (id) => channel.selectExecutionWorkspaceEntry(id),
93
+ readExecutionWorkspaceDetail: (id) => channel.readExecutionWorkspaceDetail(id),
94
+ };
95
+ }
package/src/tui/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { TuiTransport } from './tui-transport.js';
2
+ export { renderApp } from './render.js';
3
+ export type { IRenderOptions } from './render.js';
2
4
  export type { ITuiCliAdapter } from './tui-cli-adapter.js';
3
5
  export type { IDefaultTuiCliAdapterOptions } from './create-default-tui-cli-adapter.js';
4
6
  export { createDefaultTuiCliAdapter } from './create-default-tui-cli-adapter.js';
5
- export type { IRenderOptions } from './render.js';
6
7
  export type {
7
8
  TOnMissingArgsAction,
8
9
  ITuiPickerItem,