@robota-sdk/agent-transport 3.0.0-beta.64
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/LICENSE +21 -0
- package/dist/node/headless/index.cjs +1 -0
- package/dist/node/headless/index.d.ts +2 -0
- package/dist/node/headless/index.js +1 -0
- package/dist/node/headless-CWEpJXFK.js +7 -0
- package/dist/node/headless-CWEpJXFK.js.map +1 -0
- package/dist/node/headless-CsZFelG9.cjs +6 -0
- package/dist/node/http/index.cjs +1 -0
- package/dist/node/http/index.d.ts +2 -0
- package/dist/node/http/index.js +1 -0
- package/dist/node/http-CM3TJhrF.cjs +1 -0
- package/dist/node/http-DwO1AHG-.js +2 -0
- package/dist/node/http-DwO1AHG-.js.map +1 -0
- package/dist/node/index--Ti9NzQX.d.ts +64 -0
- package/dist/node/index--Ti9NzQX.d.ts.map +1 -0
- package/dist/node/index-B_rcr14p.d.ts +47 -0
- package/dist/node/index-B_rcr14p.d.ts.map +1 -0
- package/dist/node/index-C9LWCL4l.d.ts +34 -0
- package/dist/node/index-C9LWCL4l.d.ts.map +1 -0
- package/dist/node/index-CAr3ioVh.d.ts +64 -0
- package/dist/node/index-CAr3ioVh.d.ts.map +1 -0
- package/dist/node/index-CEs25wVk.d.ts +213 -0
- package/dist/node/index-CEs25wVk.d.ts.map +1 -0
- package/dist/node/index-CvXLpjJO.d.ts +213 -0
- package/dist/node/index-CvXLpjJO.d.ts.map +1 -0
- package/dist/node/index-D34WUfFH.d.ts +26 -0
- package/dist/node/index-D34WUfFH.d.ts.map +1 -0
- package/dist/node/index-Y0zHb1Bz.d.ts +47 -0
- package/dist/node/index-Y0zHb1Bz.d.ts.map +1 -0
- package/dist/node/index-k3TUjA-T.d.ts +26 -0
- package/dist/node/index-k3TUjA-T.d.ts.map +1 -0
- package/dist/node/index-nBlMTFkZ.d.ts +34 -0
- package/dist/node/index-nBlMTFkZ.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.js +1 -0
- package/dist/node/mcp/index.cjs +1 -0
- package/dist/node/mcp/index.d.ts +2 -0
- package/dist/node/mcp/index.js +1 -0
- package/dist/node/mcp-BXBwF6Wu.js +2 -0
- package/dist/node/mcp-BXBwF6Wu.js.map +1 -0
- package/dist/node/mcp-DcHuGokt.cjs +1 -0
- package/dist/node/tui/index.cjs +1 -0
- package/dist/node/tui/index.d.ts +2 -0
- package/dist/node/tui/index.js +1 -0
- package/dist/node/tui-CeD_6rSo.cjs +24 -0
- package/dist/node/tui-zmDTPk4b.js +25 -0
- package/dist/node/tui-zmDTPk4b.js.map +1 -0
- package/dist/node/ws/index.cjs +1 -0
- package/dist/node/ws/index.d.ts +2 -0
- package/dist/node/ws/index.js +1 -0
- package/dist/node/ws-B-oRccFl.js +2 -0
- package/dist/node/ws-B-oRccFl.js.map +1 -0
- package/dist/node/ws-COnIgnmn.cjs +1 -0
- package/package.json +141 -0
- package/src/headless/__tests__/headless-runner-initialization.test.ts +45 -0
- package/src/headless/__tests__/headless-runner.test.ts +484 -0
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +430 -0
- package/src/headless/__tests__/headless-transport.test.ts +268 -0
- package/src/headless/headless-runner.ts +141 -0
- package/src/headless/headless-stream-json.ts +142 -0
- package/src/headless/headless-transport.ts +43 -0
- package/src/headless/index.ts +4 -0
- package/src/http/__tests__/http-transport.test.ts +55 -0
- package/src/http/__tests__/routes.test.ts +168 -0
- package/src/http/http-transport.ts +42 -0
- package/src/http/index.ts +4 -0
- package/src/http/routes.ts +151 -0
- package/src/index.ts +5 -0
- package/src/mcp/__tests__/mcp-server.test.ts +66 -0
- package/src/mcp/__tests__/mcp-transport.test.ts +46 -0
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp-server.ts +162 -0
- package/src/mcp/mcp-transport.ts +48 -0
- package/src/tui/App.tsx +478 -0
- package/src/tui/BackgroundTaskPanel.tsx +34 -0
- package/src/tui/CjkTextInput.tsx +204 -0
- package/src/tui/ConfirmPrompt.tsx +69 -0
- package/src/tui/ExecutionWorkspaceDetailPane.tsx +62 -0
- package/src/tui/ExecutionWorkspaceSwitcher.tsx +185 -0
- package/src/tui/InkTerminal.ts +42 -0
- package/src/tui/InputArea.tsx +298 -0
- package/src/tui/InteractivePrompt.tsx +57 -0
- package/src/tui/ListPicker.tsx +94 -0
- package/src/tui/MenuSelect.tsx +103 -0
- package/src/tui/MessageList.tsx +282 -0
- package/src/tui/PermissionPrompt.tsx +84 -0
- package/src/tui/PluginTUI.tsx +256 -0
- package/src/tui/SessionPicker.tsx +66 -0
- package/src/tui/SessionStatusBar.tsx +66 -0
- package/src/tui/SlashAutocomplete.tsx +110 -0
- package/src/tui/StatusBar.tsx +213 -0
- package/src/tui/StreamingIndicator.tsx +91 -0
- package/src/tui/TextPrompt.tsx +80 -0
- package/src/tui/ToolCommandOutput.tsx +37 -0
- package/src/tui/ToolDiffBlock.tsx +30 -0
- package/src/tui/TransportTUI.tsx +116 -0
- package/src/tui/UpdateNotice.tsx +14 -0
- package/src/tui/UsageSummaryEntry.tsx +38 -0
- package/src/tui/WaveText.tsx +44 -0
- package/src/tui/__tests__/InteractivePrompt.test.tsx +82 -0
- package/src/tui/__tests__/ListPicker.test.tsx +159 -0
- package/src/tui/__tests__/MenuSelect.test.tsx +103 -0
- package/src/tui/__tests__/PluginTUI.test.tsx +167 -0
- package/src/tui/__tests__/SlashAutocomplete.test.tsx +140 -0
- package/src/tui/__tests__/TextPrompt.test.tsx +98 -0
- package/src/tui/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/tui/__tests__/abort-after-permission.test.tsx +169 -0
- package/src/tui/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/tui/__tests__/background-task-panel.test.tsx +53 -0
- package/src/tui/__tests__/background-task-row-format.test.ts +59 -0
- package/src/tui/__tests__/cjk-text-input-flow.test.ts +109 -0
- package/src/tui/__tests__/cjk-text-input.test.ts +191 -0
- package/src/tui/__tests__/command-effect-handler.test.ts +128 -0
- package/src/tui/__tests__/command-output-summary.test.ts +95 -0
- package/src/tui/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/tui/__tests__/confirm-permission-flow.test.ts +91 -0
- package/src/tui/__tests__/confirm-prompt.test.tsx +87 -0
- package/src/tui/__tests__/execution-workspace-switcher.test.tsx +110 -0
- package/src/tui/__tests__/execution-workspace-view-model.test.ts +93 -0
- package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +122 -0
- package/src/tui/__tests__/input-area-flow.test.ts +152 -0
- package/src/tui/__tests__/message-list-rendering.test.tsx +353 -0
- package/src/tui/__tests__/model-change-side-effect.test.ts +91 -0
- package/src/tui/__tests__/prompt-queue.test.tsx +255 -0
- package/src/tui/__tests__/provider-setup-pty-e2e.test.ts +233 -0
- package/src/tui/__tests__/render-markdown.test.ts +72 -0
- package/src/tui/__tests__/selection-flow.test.ts +61 -0
- package/src/tui/__tests__/slash-routing-effects.test.ts +225 -0
- package/src/tui/__tests__/status-activity.test.ts +71 -0
- package/src/tui/__tests__/status-bar.test.tsx +157 -0
- package/src/tui/__tests__/streaming-indicator.test.tsx +137 -0
- package/src/tui/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/tui/__tests__/tui-state-manager.test.ts +401 -0
- package/src/tui/background-task-row-format.ts +52 -0
- package/src/tui/command-output-summary.ts +122 -0
- package/src/tui/execution-workspace-view-model.ts +123 -0
- package/src/tui/flows/cjk-text-input-flow.ts +285 -0
- package/src/tui/flows/confirm-prompt-flow.ts +45 -0
- package/src/tui/flows/input-area-flow.ts +186 -0
- package/src/tui/flows/permission-prompt-flow.ts +76 -0
- package/src/tui/flows/selection-flow.ts +126 -0
- package/src/tui/flows/text-prompt-flow.ts +98 -0
- package/src/tui/hooks/command-effect-handler.ts +98 -0
- package/src/tui/hooks/command-effect-queue.ts +39 -0
- package/src/tui/hooks/model-change-side-effect.ts +63 -0
- package/src/tui/hooks/side-effects-types.ts +38 -0
- package/src/tui/hooks/use-interactive-session-init.ts +50 -0
- package/src/tui/hooks/useAutocomplete.ts +85 -0
- package/src/tui/hooks/useInteractiveSession.ts +273 -0
- package/src/tui/hooks/usePermissionQueue.ts +51 -0
- package/src/tui/hooks/usePluginCallbacks.ts +30 -0
- package/src/tui/hooks/usePluginScreenData.ts +84 -0
- package/src/tui/hooks/useSideEffects.ts +210 -0
- package/src/tui/hooks/useSlashRouting.ts +117 -0
- package/src/tui/hooks/useStatusLineSettings.ts +35 -0
- package/src/tui/index.ts +3 -0
- package/src/tui/plugin-tui-handlers.ts +163 -0
- package/src/tui/render-markdown.ts +129 -0
- package/src/tui/render.tsx +60 -0
- package/src/tui/status-activity.ts +63 -0
- package/src/tui/tui-cli-adapter-context.tsx +12 -0
- package/src/tui/tui-cli-adapter.ts +25 -0
- package/src/tui/tui-state-manager.ts +225 -0
- package/src/tui/tui-transport.ts +32 -0
- package/src/tui/types.ts +14 -0
- package/src/tui/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/tui/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/tui/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/tui/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/tui/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/tui/utils/edit-diff.ts +152 -0
- package/src/tui/utils/paste-labels.ts +9 -0
- package/src/tui/utils/tool-call-extractor.ts +91 -0
- package/src/tui/utils/tool-diff-summary.ts +75 -0
- package/src/ws/__tests__/ws-handler.test.ts +407 -0
- package/src/ws/__tests__/ws-transport.test.ts +53 -0
- package/src/ws/index.ts +13 -0
- package/src/ws/ws-background-messages.ts +170 -0
- package/src/ws/ws-handler.ts +279 -0
- package/src/ws/ws-protocol.ts +76 -0
- package/src/ws/ws-transport-configurable.ts +123 -0
- package/src/ws/ws-transport.ts +42 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// packages/agent-cli/src/ui/__tests__/PluginTUI.test.tsx
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import PluginTUI from '../PluginTUI.js';
|
|
6
|
+
import type { ICommandPluginAdapter } from '@robota-sdk/agent-framework';
|
|
7
|
+
|
|
8
|
+
function mockCallbacks(): ICommandPluginAdapter {
|
|
9
|
+
return {
|
|
10
|
+
listInstalled: vi.fn().mockResolvedValue([]),
|
|
11
|
+
listAvailablePlugins: vi.fn().mockResolvedValue([]),
|
|
12
|
+
install: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
uninstall: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
enable: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
disable: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
marketplaceAdd: vi.fn().mockResolvedValue('test-marketplace'),
|
|
17
|
+
marketplaceRemove: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
marketplaceUpdate: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
marketplaceList: vi.fn().mockResolvedValue([]),
|
|
20
|
+
reloadPlugins: vi.fn().mockResolvedValue({ loadedPluginCount: 0 }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('PluginTUI', () => {
|
|
25
|
+
it('renders main menu with Marketplace and Installed Plugins', () => {
|
|
26
|
+
const { lastFrame } = render(<PluginTUI callbacks={mockCallbacks()} onClose={() => {}} />);
|
|
27
|
+
const frame = lastFrame()!;
|
|
28
|
+
expect(frame).toContain('Marketplace');
|
|
29
|
+
expect(frame).toContain('Installed Plugins');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('calls onClose when Escape on main menu', async () => {
|
|
33
|
+
let closed = false;
|
|
34
|
+
const { stdin } = render(
|
|
35
|
+
<PluginTUI
|
|
36
|
+
callbacks={mockCallbacks()}
|
|
37
|
+
onClose={() => {
|
|
38
|
+
closed = true;
|
|
39
|
+
}}
|
|
40
|
+
/>,
|
|
41
|
+
);
|
|
42
|
+
stdin.write('\x1B');
|
|
43
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
44
|
+
expect(closed).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('navigates to installed plugins and shows actions', async () => {
|
|
48
|
+
const cbs = mockCallbacks();
|
|
49
|
+
cbs.listInstalled = vi
|
|
50
|
+
.fn()
|
|
51
|
+
.mockResolvedValue([{ name: 'my-plugin', description: 'A plugin', enabled: true }]);
|
|
52
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
53
|
+
stdin.write('\x1B[B'); // Down to "Installed Plugins"
|
|
54
|
+
stdin.write('\r');
|
|
55
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
56
|
+
expect(lastFrame()!).toContain('my-plugin');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('navigates to marketplace list on Enter', async () => {
|
|
60
|
+
const cbs = mockCallbacks();
|
|
61
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
62
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
63
|
+
stdin.write('\r'); // Enter on "Marketplace"
|
|
64
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
65
|
+
const frame = lastFrame()!;
|
|
66
|
+
expect(frame).toContain('Add Marketplace');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Regression: arrow keys must work after navigating to a sub-screen (resolvedRef reset via key)
|
|
70
|
+
it('arrow keys work in marketplace list after navigating from main', async () => {
|
|
71
|
+
const cbs = mockCallbacks();
|
|
72
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([
|
|
73
|
+
{ name: 'mp-a', type: 'github' },
|
|
74
|
+
{ name: 'mp-b', type: 'git' },
|
|
75
|
+
]);
|
|
76
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
77
|
+
stdin.write('\r'); // Enter on "Marketplace"
|
|
78
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
79
|
+
// Should be on marketplace-list with arrow keys working
|
|
80
|
+
stdin.write('\x1B[B'); // Down from "Add Marketplace" to "mp-a"
|
|
81
|
+
stdin.write('\x1B[B'); // Down to "mp-b"
|
|
82
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
83
|
+
const frame = lastFrame()!;
|
|
84
|
+
// "mp-b" should be highlighted (has > prefix)
|
|
85
|
+
expect(frame).toContain('mp-b');
|
|
86
|
+
// Select mp-b → should navigate to marketplace-action
|
|
87
|
+
stdin.write('\r');
|
|
88
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
89
|
+
expect(lastFrame()!).toContain('Browse plugins');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Regression: installed plugin browse shows uninstall for already-installed plugins
|
|
93
|
+
it('selecting installed plugin in browse shows uninstall action', async () => {
|
|
94
|
+
const cbs = mockCallbacks();
|
|
95
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
96
|
+
cbs.listAvailablePlugins = vi.fn().mockResolvedValue([
|
|
97
|
+
{ name: 'my-plugin', description: 'A plugin', installed: true },
|
|
98
|
+
{ name: 'new-plugin', description: 'Not installed', installed: false },
|
|
99
|
+
]);
|
|
100
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
101
|
+
// Main → Marketplace
|
|
102
|
+
stdin.write('\r');
|
|
103
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
104
|
+
// Marketplace list → select test-mp
|
|
105
|
+
stdin.write('\x1B[B'); // Down to "test-mp"
|
|
106
|
+
stdin.write('\r');
|
|
107
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
108
|
+
// Marketplace action → Browse plugins
|
|
109
|
+
stdin.write('\r');
|
|
110
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
111
|
+
// Browse → select installed "my-plugin"
|
|
112
|
+
stdin.write('\r');
|
|
113
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
114
|
+
// Should show uninstall action, not install scope
|
|
115
|
+
expect(lastFrame()!).toContain('Uninstall');
|
|
116
|
+
expect(lastFrame()!).not.toContain('User scope');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Regression: uninstalled plugin in browse shows install scope selection
|
|
120
|
+
it('selecting uninstalled plugin in browse shows install scope', async () => {
|
|
121
|
+
const cbs = mockCallbacks();
|
|
122
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
123
|
+
cbs.listAvailablePlugins = vi
|
|
124
|
+
.fn()
|
|
125
|
+
.mockResolvedValue([{ name: 'new-plugin', description: 'Not installed', installed: false }]);
|
|
126
|
+
const { stdin, lastFrame } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
127
|
+
// Main → Marketplace
|
|
128
|
+
stdin.write('\r');
|
|
129
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
130
|
+
// Marketplace list → select test-mp
|
|
131
|
+
stdin.write('\x1B[B');
|
|
132
|
+
stdin.write('\r');
|
|
133
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
134
|
+
// Marketplace action → Browse plugins
|
|
135
|
+
stdin.write('\r');
|
|
136
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
137
|
+
// Browse → select "new-plugin"
|
|
138
|
+
stdin.write('\r');
|
|
139
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
140
|
+
// Should show install scope, not uninstall
|
|
141
|
+
expect(lastFrame()!).toContain('User scope');
|
|
142
|
+
expect(lastFrame()!).toContain('Project scope');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Regression: install passes name@marketplace format to callback
|
|
146
|
+
it('install callback receives pluginId in name@marketplace format', async () => {
|
|
147
|
+
const cbs = mockCallbacks();
|
|
148
|
+
cbs.marketplaceList = vi.fn().mockResolvedValue([{ name: 'test-mp', type: 'github' }]);
|
|
149
|
+
cbs.listAvailablePlugins = vi
|
|
150
|
+
.fn()
|
|
151
|
+
.mockResolvedValue([{ name: 'new-plugin', description: 'A plugin', installed: false }]);
|
|
152
|
+
const { stdin } = render(<PluginTUI callbacks={cbs} onClose={() => {}} />);
|
|
153
|
+
// Main → Marketplace → test-mp → Browse → new-plugin → User scope
|
|
154
|
+
stdin.write('\r');
|
|
155
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
156
|
+
stdin.write('\x1B[B');
|
|
157
|
+
stdin.write('\r');
|
|
158
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
159
|
+
stdin.write('\r'); // Browse plugins
|
|
160
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
161
|
+
stdin.write('\r'); // new-plugin
|
|
162
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
163
|
+
stdin.write('\r'); // User scope
|
|
164
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
165
|
+
expect(cbs.install).toHaveBeenCalledWith('new-plugin@test-mp', 'user');
|
|
166
|
+
}, 15000);
|
|
167
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import SlashAutocomplete from '../SlashAutocomplete.js';
|
|
5
|
+
import type { ICommand } from '@robota-sdk/agent-framework';
|
|
6
|
+
|
|
7
|
+
// ink-testing-library fixes stdout.columns = 100
|
|
8
|
+
// outer box chrome = 4 → rowWidth = 96 in tests
|
|
9
|
+
const NAME_COL_MAX = 20;
|
|
10
|
+
|
|
11
|
+
function makeCmd(name: string, description: string): ICommand {
|
|
12
|
+
return { name, description } as ICommand;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('SlashAutocomplete', () => {
|
|
16
|
+
it('renders nothing when not visible', () => {
|
|
17
|
+
const { lastFrame } = render(
|
|
18
|
+
<SlashAutocomplete
|
|
19
|
+
commands={[makeCmd('help', 'Show help')]}
|
|
20
|
+
selectedIndex={0}
|
|
21
|
+
visible={false}
|
|
22
|
+
/>,
|
|
23
|
+
);
|
|
24
|
+
expect(lastFrame()).toBe('');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('renders nothing when command list is empty', () => {
|
|
28
|
+
const { lastFrame } = render(
|
|
29
|
+
<SlashAutocomplete commands={[]} selectedIndex={0} visible={true} />,
|
|
30
|
+
);
|
|
31
|
+
expect(lastFrame()).toBe('');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('aligns descriptions to the same column across all rows', () => {
|
|
35
|
+
const { lastFrame } = render(
|
|
36
|
+
<SlashAutocomplete
|
|
37
|
+
commands={[
|
|
38
|
+
makeCmd('go', 'Short'),
|
|
39
|
+
makeCmd('session-persistence', 'Manage sessions'),
|
|
40
|
+
makeCmd('help', 'Show help'),
|
|
41
|
+
]}
|
|
42
|
+
selectedIndex={0}
|
|
43
|
+
visible={true}
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
const frame = lastFrame()!;
|
|
47
|
+
const lines = frame
|
|
48
|
+
.split('\n')
|
|
49
|
+
.filter((l) => l.includes('Short') || l.includes('Manage') || l.includes('Show help'));
|
|
50
|
+
// All description texts should start at the same column index
|
|
51
|
+
const descPositions = lines.map((l) => {
|
|
52
|
+
// find position after the two-space separator following the name
|
|
53
|
+
const match = / {2}\S/.exec(l.slice(l.indexOf('/') + 1));
|
|
54
|
+
return match ? l.indexOf('/') + 1 + match.index + 2 : -1;
|
|
55
|
+
});
|
|
56
|
+
expect(new Set(descPositions).size).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('pads short names to match the longest name in visible set', () => {
|
|
60
|
+
const { lastFrame } = render(
|
|
61
|
+
<SlashAutocomplete
|
|
62
|
+
commands={[makeCmd('go', 'Run'), makeCmd('help', 'Show help')]}
|
|
63
|
+
selectedIndex={0}
|
|
64
|
+
visible={true}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
const frame = lastFrame()!;
|
|
68
|
+
// 'go' should be padded to 4 chars (length of 'help')
|
|
69
|
+
expect(frame).toContain('/go ');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('caps name column at NAME_COL_MAX and truncates with ellipsis', () => {
|
|
73
|
+
const longName = 'a'.repeat(NAME_COL_MAX + 5);
|
|
74
|
+
const { lastFrame } = render(
|
|
75
|
+
<SlashAutocomplete
|
|
76
|
+
commands={[makeCmd(longName, 'Description'), makeCmd('go', 'Short')]}
|
|
77
|
+
selectedIndex={0}
|
|
78
|
+
visible={true}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
const frame = lastFrame()!;
|
|
82
|
+
expect(frame).toContain('…');
|
|
83
|
+
expect(frame).not.toContain(longName);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not truncate name exactly at NAME_COL_MAX', () => {
|
|
87
|
+
const exactName = 'b'.repeat(NAME_COL_MAX);
|
|
88
|
+
const { lastFrame } = render(
|
|
89
|
+
<SlashAutocomplete
|
|
90
|
+
commands={[makeCmd(exactName, 'Desc'), makeCmd('go', 'Short')]}
|
|
91
|
+
selectedIndex={0}
|
|
92
|
+
visible={true}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
const frame = lastFrame()!;
|
|
96
|
+
expect(frame).toContain(exactName);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('truncates long row text with ellipsis via Ink wrap', () => {
|
|
100
|
+
const longDesc = 'X'.repeat(100);
|
|
101
|
+
const { lastFrame } = render(
|
|
102
|
+
<SlashAutocomplete commands={[makeCmd('cmd', longDesc)]} selectedIndex={0} visible={true} />,
|
|
103
|
+
);
|
|
104
|
+
expect(lastFrame()).toContain('…');
|
|
105
|
+
expect(lastFrame()).not.toContain(longDesc);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles undefined description gracefully', () => {
|
|
109
|
+
const cmd = { name: 'cmd' } as ICommand;
|
|
110
|
+
const { lastFrame } = render(
|
|
111
|
+
<SlashAutocomplete commands={[cmd]} selectedIndex={0} visible={true} />,
|
|
112
|
+
);
|
|
113
|
+
expect(lastFrame()).toContain('cmd');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('shows slash prefix in normal mode', () => {
|
|
117
|
+
const { lastFrame } = render(
|
|
118
|
+
<SlashAutocomplete
|
|
119
|
+
commands={[makeCmd('help', 'Show help')]}
|
|
120
|
+
selectedIndex={0}
|
|
121
|
+
visible={true}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
expect(lastFrame()).toContain('/help');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('omits slash prefix in subcommand mode', () => {
|
|
128
|
+
const { lastFrame } = render(
|
|
129
|
+
<SlashAutocomplete
|
|
130
|
+
commands={[makeCmd('run', 'Run task')]}
|
|
131
|
+
selectedIndex={0}
|
|
132
|
+
visible={true}
|
|
133
|
+
isSubcommandMode={true}
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
const frame = lastFrame()!;
|
|
137
|
+
expect(frame).not.toContain('/run');
|
|
138
|
+
expect(frame).toContain('Run task');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import TextPrompt from '../TextPrompt.js';
|
|
5
|
+
|
|
6
|
+
describe('TextPrompt', () => {
|
|
7
|
+
it('renders title', () => {
|
|
8
|
+
const { lastFrame } = render(
|
|
9
|
+
<TextPrompt title="Enter URL" onSubmit={() => {}} onCancel={() => {}} />,
|
|
10
|
+
);
|
|
11
|
+
expect(lastFrame()!).toContain('Enter URL');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('renders placeholder when provided', () => {
|
|
15
|
+
const { lastFrame } = render(
|
|
16
|
+
<TextPrompt title="Enter" placeholder="owner/repo" onSubmit={() => {}} onCancel={() => {}} />,
|
|
17
|
+
);
|
|
18
|
+
expect(lastFrame()!).toContain('owner/repo');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('calls onCancel on Escape', async () => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
const { stdin } = render(
|
|
24
|
+
<TextPrompt
|
|
25
|
+
title="Enter"
|
|
26
|
+
onSubmit={() => {}}
|
|
27
|
+
onCancel={() => {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
}}
|
|
30
|
+
/>,
|
|
31
|
+
);
|
|
32
|
+
stdin.write('\x1B');
|
|
33
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
34
|
+
expect(cancelled).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('calls onSubmit with value on Enter', () => {
|
|
38
|
+
let submitted = '';
|
|
39
|
+
const { stdin } = render(
|
|
40
|
+
<TextPrompt
|
|
41
|
+
title="Enter"
|
|
42
|
+
onSubmit={(v) => {
|
|
43
|
+
submitted = v;
|
|
44
|
+
}}
|
|
45
|
+
onCancel={() => {}}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
stdin.write('hello');
|
|
49
|
+
stdin.write('\r');
|
|
50
|
+
expect(submitted).toBe('hello');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('can submit an empty value when allowed', () => {
|
|
54
|
+
let submitted = 'not-called';
|
|
55
|
+
const { stdin } = render(
|
|
56
|
+
<TextPrompt
|
|
57
|
+
title="Enter"
|
|
58
|
+
allowEmpty
|
|
59
|
+
onSubmit={(v) => {
|
|
60
|
+
submitted = v;
|
|
61
|
+
}}
|
|
62
|
+
onCancel={() => {}}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
stdin.write('\r');
|
|
66
|
+
expect(submitted).toBe('');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('masks typed values when requested', async () => {
|
|
70
|
+
const { stdin, lastFrame } = render(
|
|
71
|
+
<TextPrompt title="Secret" masked onSubmit={() => {}} onCancel={() => {}} />,
|
|
72
|
+
);
|
|
73
|
+
stdin.write('abc');
|
|
74
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
75
|
+
expect(lastFrame()!).toContain('***');
|
|
76
|
+
expect(lastFrame()!).not.toContain('abc');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('shows validation error and blocks submit', async () => {
|
|
80
|
+
let submitted = false;
|
|
81
|
+
const validate = (v: string) => (v.length < 3 ? 'Too short' : undefined);
|
|
82
|
+
const { stdin, lastFrame } = render(
|
|
83
|
+
<TextPrompt
|
|
84
|
+
title="Enter"
|
|
85
|
+
onSubmit={() => {
|
|
86
|
+
submitted = true;
|
|
87
|
+
}}
|
|
88
|
+
onCancel={() => {}}
|
|
89
|
+
validate={validate}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
stdin.write('ab');
|
|
93
|
+
stdin.write('\r');
|
|
94
|
+
expect(submitted).toBe(false);
|
|
95
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
96
|
+
expect(lastFrame()!).toContain('Too short');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import UpdateNotice from '../UpdateNotice.js';
|
|
5
|
+
|
|
6
|
+
describe('UpdateNotice', () => {
|
|
7
|
+
it('renders an update notice outside session history', () => {
|
|
8
|
+
const { lastFrame } = render(
|
|
9
|
+
<UpdateNotice message="Robota update available. Run npm install -g '@robota-sdk/agent-cli@latest'." />,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
expect(lastFrame()).toContain('Robota update available');
|
|
13
|
+
expect(lastFrame()).toContain('npm install');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: ESC abort after permission prompt was shown and dismissed.
|
|
3
|
+
* Verifies that the global ESC handler remains available after overlays close.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useCallback } from 'react';
|
|
7
|
+
import { render } from 'ink-testing-library';
|
|
8
|
+
import { Box, Text, useInput } from 'ink';
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Simulates App's global ESC handler with permission prompt overlay guard.
|
|
13
|
+
* 1. Start with "thinking" active
|
|
14
|
+
* 2. Permission prompt appears (App-level ESC ignores while overlay is active)
|
|
15
|
+
* 3. Permission resolved (App-level ESC handles abort again)
|
|
16
|
+
* 4. ESC should trigger abort
|
|
17
|
+
*/
|
|
18
|
+
function AbortAfterPermissionApp({
|
|
19
|
+
onAbort,
|
|
20
|
+
onPermissionReady,
|
|
21
|
+
}: {
|
|
22
|
+
onAbort: () => void;
|
|
23
|
+
onPermissionReady: (grantPermission: () => void) => void;
|
|
24
|
+
}): React.ReactElement {
|
|
25
|
+
const [isThinking, setIsThinking] = useState(true);
|
|
26
|
+
const [permissionRequest, setPermissionRequest] = useState<{
|
|
27
|
+
resolve: () => void;
|
|
28
|
+
} | null>(null);
|
|
29
|
+
const [aborted, setAborted] = useState(false);
|
|
30
|
+
|
|
31
|
+
// Simulate permission prompt appearing after mount
|
|
32
|
+
const showPermission = useCallback(() => {
|
|
33
|
+
setPermissionRequest({
|
|
34
|
+
resolve: () => {
|
|
35
|
+
setPermissionRequest(null);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
// Give parent a way to grant permission
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
// Show permission prompt immediately
|
|
43
|
+
const pr = {
|
|
44
|
+
resolve: () => setPermissionRequest(null),
|
|
45
|
+
};
|
|
46
|
+
setPermissionRequest(pr);
|
|
47
|
+
onPermissionReady(() => pr.resolve());
|
|
48
|
+
}, [onPermissionReady]);
|
|
49
|
+
|
|
50
|
+
// App's ESC handler — same pattern as real App.tsx
|
|
51
|
+
useInput((_input: string, key: { escape: boolean }) => {
|
|
52
|
+
if (!key.escape || !isThinking) return;
|
|
53
|
+
if (permissionRequest) return;
|
|
54
|
+
setAborted(true);
|
|
55
|
+
onAbort();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Permission prompt's own useInput (when active)
|
|
59
|
+
useInput(
|
|
60
|
+
(_input: string, key: { return: boolean }) => {
|
|
61
|
+
if (key.return && permissionRequest) {
|
|
62
|
+
permissionRequest.resolve();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{ isActive: !!permissionRequest },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Box flexDirection="column">
|
|
70
|
+
{permissionRequest && <Text color="yellow">[Permission Required]</Text>}
|
|
71
|
+
{!permissionRequest && isThinking && <Text color="cyan">Streaming...</Text>}
|
|
72
|
+
{aborted && <Text color="red">Aborted!</Text>}
|
|
73
|
+
<Text dimColor>
|
|
74
|
+
thinking={String(isThinking)} permission={String(!!permissionRequest)} aborted=
|
|
75
|
+
{String(aborted)}
|
|
76
|
+
</Text>
|
|
77
|
+
</Box>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('ESC abort after permission prompt', () => {
|
|
82
|
+
it('ESC works when no permission prompt was shown', async () => {
|
|
83
|
+
let abortCalled = false;
|
|
84
|
+
const grantHolder: { fn: (() => void) | null } = { fn: null };
|
|
85
|
+
|
|
86
|
+
const { stdin, lastFrame } = render(
|
|
87
|
+
<AbortAfterPermissionApp
|
|
88
|
+
onAbort={() => {
|
|
89
|
+
abortCalled = true;
|
|
90
|
+
}}
|
|
91
|
+
onPermissionReady={(fn) => {
|
|
92
|
+
grantHolder.fn = fn;
|
|
93
|
+
}}
|
|
94
|
+
/>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Wait for mount
|
|
98
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
99
|
+
|
|
100
|
+
// Grant permission immediately
|
|
101
|
+
grantHolder.fn?.();
|
|
102
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
103
|
+
|
|
104
|
+
// Now ESC should work
|
|
105
|
+
stdin.write('\x1B');
|
|
106
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
107
|
+
|
|
108
|
+
expect(abortCalled).toBe(true);
|
|
109
|
+
expect(lastFrame()!).toContain('Aborted!');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ESC works AFTER permission prompt was shown and dismissed', async () => {
|
|
113
|
+
let abortCalled = false;
|
|
114
|
+
const grantHolder: { fn: (() => void) | null } = { fn: null };
|
|
115
|
+
|
|
116
|
+
const { stdin, lastFrame } = render(
|
|
117
|
+
<AbortAfterPermissionApp
|
|
118
|
+
onAbort={() => {
|
|
119
|
+
abortCalled = true;
|
|
120
|
+
}}
|
|
121
|
+
onPermissionReady={(fn) => {
|
|
122
|
+
grantHolder.fn = fn;
|
|
123
|
+
}}
|
|
124
|
+
/>,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Wait for permission prompt to appear
|
|
128
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
129
|
+
expect(lastFrame()!).toContain('[Permission Required]');
|
|
130
|
+
|
|
131
|
+
// Grant permission (dismiss prompt)
|
|
132
|
+
grantHolder.fn?.();
|
|
133
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
134
|
+
|
|
135
|
+
// Permission dismissed, streaming should show
|
|
136
|
+
expect(lastFrame()!).toContain('Streaming...');
|
|
137
|
+
expect(lastFrame()!).not.toContain('[Permission Required]');
|
|
138
|
+
|
|
139
|
+
// Now press ESC — should trigger abort
|
|
140
|
+
stdin.write('\x1B');
|
|
141
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
142
|
+
|
|
143
|
+
expect(abortCalled).toBe(true);
|
|
144
|
+
expect(lastFrame()!).toContain('Aborted!');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('ESC does NOT work during permission prompt', async () => {
|
|
148
|
+
let abortCalled = false;
|
|
149
|
+
|
|
150
|
+
const { stdin, lastFrame } = render(
|
|
151
|
+
<AbortAfterPermissionApp
|
|
152
|
+
onAbort={() => {
|
|
153
|
+
abortCalled = true;
|
|
154
|
+
}}
|
|
155
|
+
onPermissionReady={() => {}}
|
|
156
|
+
/>,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
160
|
+
expect(lastFrame()!).toContain('[Permission Required]');
|
|
161
|
+
|
|
162
|
+
// ESC during permission prompt should NOT trigger abort
|
|
163
|
+
stdin.write('\x1B');
|
|
164
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
165
|
+
|
|
166
|
+
expect(abortCalled).toBe(false);
|
|
167
|
+
expect(lastFrame()!).not.toContain('Aborted!');
|
|
168
|
+
});
|
|
169
|
+
});
|