@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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/dist/node/headless/index.cjs +1 -0
  3. package/dist/node/headless/index.d.ts +2 -0
  4. package/dist/node/headless/index.js +1 -0
  5. package/dist/node/headless-CWEpJXFK.js +7 -0
  6. package/dist/node/headless-CWEpJXFK.js.map +1 -0
  7. package/dist/node/headless-CsZFelG9.cjs +6 -0
  8. package/dist/node/http/index.cjs +1 -0
  9. package/dist/node/http/index.d.ts +2 -0
  10. package/dist/node/http/index.js +1 -0
  11. package/dist/node/http-CM3TJhrF.cjs +1 -0
  12. package/dist/node/http-DwO1AHG-.js +2 -0
  13. package/dist/node/http-DwO1AHG-.js.map +1 -0
  14. package/dist/node/index--Ti9NzQX.d.ts +64 -0
  15. package/dist/node/index--Ti9NzQX.d.ts.map +1 -0
  16. package/dist/node/index-B_rcr14p.d.ts +47 -0
  17. package/dist/node/index-B_rcr14p.d.ts.map +1 -0
  18. package/dist/node/index-C9LWCL4l.d.ts +34 -0
  19. package/dist/node/index-C9LWCL4l.d.ts.map +1 -0
  20. package/dist/node/index-CAr3ioVh.d.ts +64 -0
  21. package/dist/node/index-CAr3ioVh.d.ts.map +1 -0
  22. package/dist/node/index-CEs25wVk.d.ts +213 -0
  23. package/dist/node/index-CEs25wVk.d.ts.map +1 -0
  24. package/dist/node/index-CvXLpjJO.d.ts +213 -0
  25. package/dist/node/index-CvXLpjJO.d.ts.map +1 -0
  26. package/dist/node/index-D34WUfFH.d.ts +26 -0
  27. package/dist/node/index-D34WUfFH.d.ts.map +1 -0
  28. package/dist/node/index-Y0zHb1Bz.d.ts +47 -0
  29. package/dist/node/index-Y0zHb1Bz.d.ts.map +1 -0
  30. package/dist/node/index-k3TUjA-T.d.ts +26 -0
  31. package/dist/node/index-k3TUjA-T.d.ts.map +1 -0
  32. package/dist/node/index-nBlMTFkZ.d.ts +34 -0
  33. package/dist/node/index-nBlMTFkZ.d.ts.map +1 -0
  34. package/dist/node/index.cjs +1 -0
  35. package/dist/node/index.d.ts +6 -0
  36. package/dist/node/index.js +1 -0
  37. package/dist/node/mcp/index.cjs +1 -0
  38. package/dist/node/mcp/index.d.ts +2 -0
  39. package/dist/node/mcp/index.js +1 -0
  40. package/dist/node/mcp-BXBwF6Wu.js +2 -0
  41. package/dist/node/mcp-BXBwF6Wu.js.map +1 -0
  42. package/dist/node/mcp-DcHuGokt.cjs +1 -0
  43. package/dist/node/tui/index.cjs +1 -0
  44. package/dist/node/tui/index.d.ts +2 -0
  45. package/dist/node/tui/index.js +1 -0
  46. package/dist/node/tui-CeD_6rSo.cjs +24 -0
  47. package/dist/node/tui-zmDTPk4b.js +25 -0
  48. package/dist/node/tui-zmDTPk4b.js.map +1 -0
  49. package/dist/node/ws/index.cjs +1 -0
  50. package/dist/node/ws/index.d.ts +2 -0
  51. package/dist/node/ws/index.js +1 -0
  52. package/dist/node/ws-B-oRccFl.js +2 -0
  53. package/dist/node/ws-B-oRccFl.js.map +1 -0
  54. package/dist/node/ws-COnIgnmn.cjs +1 -0
  55. package/package.json +141 -0
  56. package/src/headless/__tests__/headless-runner-initialization.test.ts +45 -0
  57. package/src/headless/__tests__/headless-runner.test.ts +484 -0
  58. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +430 -0
  59. package/src/headless/__tests__/headless-transport.test.ts +268 -0
  60. package/src/headless/headless-runner.ts +141 -0
  61. package/src/headless/headless-stream-json.ts +142 -0
  62. package/src/headless/headless-transport.ts +43 -0
  63. package/src/headless/index.ts +4 -0
  64. package/src/http/__tests__/http-transport.test.ts +55 -0
  65. package/src/http/__tests__/routes.test.ts +168 -0
  66. package/src/http/http-transport.ts +42 -0
  67. package/src/http/index.ts +4 -0
  68. package/src/http/routes.ts +151 -0
  69. package/src/index.ts +5 -0
  70. package/src/mcp/__tests__/mcp-server.test.ts +66 -0
  71. package/src/mcp/__tests__/mcp-transport.test.ts +46 -0
  72. package/src/mcp/index.ts +4 -0
  73. package/src/mcp/mcp-server.ts +162 -0
  74. package/src/mcp/mcp-transport.ts +48 -0
  75. package/src/tui/App.tsx +478 -0
  76. package/src/tui/BackgroundTaskPanel.tsx +34 -0
  77. package/src/tui/CjkTextInput.tsx +204 -0
  78. package/src/tui/ConfirmPrompt.tsx +69 -0
  79. package/src/tui/ExecutionWorkspaceDetailPane.tsx +62 -0
  80. package/src/tui/ExecutionWorkspaceSwitcher.tsx +185 -0
  81. package/src/tui/InkTerminal.ts +42 -0
  82. package/src/tui/InputArea.tsx +298 -0
  83. package/src/tui/InteractivePrompt.tsx +57 -0
  84. package/src/tui/ListPicker.tsx +94 -0
  85. package/src/tui/MenuSelect.tsx +103 -0
  86. package/src/tui/MessageList.tsx +282 -0
  87. package/src/tui/PermissionPrompt.tsx +84 -0
  88. package/src/tui/PluginTUI.tsx +256 -0
  89. package/src/tui/SessionPicker.tsx +66 -0
  90. package/src/tui/SessionStatusBar.tsx +66 -0
  91. package/src/tui/SlashAutocomplete.tsx +110 -0
  92. package/src/tui/StatusBar.tsx +213 -0
  93. package/src/tui/StreamingIndicator.tsx +91 -0
  94. package/src/tui/TextPrompt.tsx +80 -0
  95. package/src/tui/ToolCommandOutput.tsx +37 -0
  96. package/src/tui/ToolDiffBlock.tsx +30 -0
  97. package/src/tui/TransportTUI.tsx +116 -0
  98. package/src/tui/UpdateNotice.tsx +14 -0
  99. package/src/tui/UsageSummaryEntry.tsx +38 -0
  100. package/src/tui/WaveText.tsx +44 -0
  101. package/src/tui/__tests__/InteractivePrompt.test.tsx +82 -0
  102. package/src/tui/__tests__/ListPicker.test.tsx +159 -0
  103. package/src/tui/__tests__/MenuSelect.test.tsx +103 -0
  104. package/src/tui/__tests__/PluginTUI.test.tsx +167 -0
  105. package/src/tui/__tests__/SlashAutocomplete.test.tsx +140 -0
  106. package/src/tui/__tests__/TextPrompt.test.tsx +98 -0
  107. package/src/tui/__tests__/UpdateNotice.test.tsx +15 -0
  108. package/src/tui/__tests__/abort-after-permission.test.tsx +169 -0
  109. package/src/tui/__tests__/abort-streaming-e2e.test.tsx +183 -0
  110. package/src/tui/__tests__/background-task-panel.test.tsx +53 -0
  111. package/src/tui/__tests__/background-task-row-format.test.ts +59 -0
  112. package/src/tui/__tests__/cjk-text-input-flow.test.ts +109 -0
  113. package/src/tui/__tests__/cjk-text-input.test.ts +191 -0
  114. package/src/tui/__tests__/command-effect-handler.test.ts +128 -0
  115. package/src/tui/__tests__/command-output-summary.test.ts +95 -0
  116. package/src/tui/__tests__/compact-event-bridge.test.ts +20 -0
  117. package/src/tui/__tests__/confirm-permission-flow.test.ts +91 -0
  118. package/src/tui/__tests__/confirm-prompt.test.tsx +87 -0
  119. package/src/tui/__tests__/execution-workspace-switcher.test.tsx +110 -0
  120. package/src/tui/__tests__/execution-workspace-view-model.test.ts +93 -0
  121. package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +122 -0
  122. package/src/tui/__tests__/input-area-flow.test.ts +152 -0
  123. package/src/tui/__tests__/message-list-rendering.test.tsx +353 -0
  124. package/src/tui/__tests__/model-change-side-effect.test.ts +91 -0
  125. package/src/tui/__tests__/prompt-queue.test.tsx +255 -0
  126. package/src/tui/__tests__/provider-setup-pty-e2e.test.ts +233 -0
  127. package/src/tui/__tests__/render-markdown.test.ts +72 -0
  128. package/src/tui/__tests__/selection-flow.test.ts +61 -0
  129. package/src/tui/__tests__/slash-routing-effects.test.ts +225 -0
  130. package/src/tui/__tests__/status-activity.test.ts +71 -0
  131. package/src/tui/__tests__/status-bar.test.tsx +157 -0
  132. package/src/tui/__tests__/streaming-indicator.test.tsx +137 -0
  133. package/src/tui/__tests__/text-prompt-flow.test.ts +77 -0
  134. package/src/tui/__tests__/tui-state-manager.test.ts +401 -0
  135. package/src/tui/background-task-row-format.ts +52 -0
  136. package/src/tui/command-output-summary.ts +122 -0
  137. package/src/tui/execution-workspace-view-model.ts +123 -0
  138. package/src/tui/flows/cjk-text-input-flow.ts +285 -0
  139. package/src/tui/flows/confirm-prompt-flow.ts +45 -0
  140. package/src/tui/flows/input-area-flow.ts +186 -0
  141. package/src/tui/flows/permission-prompt-flow.ts +76 -0
  142. package/src/tui/flows/selection-flow.ts +126 -0
  143. package/src/tui/flows/text-prompt-flow.ts +98 -0
  144. package/src/tui/hooks/command-effect-handler.ts +98 -0
  145. package/src/tui/hooks/command-effect-queue.ts +39 -0
  146. package/src/tui/hooks/model-change-side-effect.ts +63 -0
  147. package/src/tui/hooks/side-effects-types.ts +38 -0
  148. package/src/tui/hooks/use-interactive-session-init.ts +50 -0
  149. package/src/tui/hooks/useAutocomplete.ts +85 -0
  150. package/src/tui/hooks/useInteractiveSession.ts +273 -0
  151. package/src/tui/hooks/usePermissionQueue.ts +51 -0
  152. package/src/tui/hooks/usePluginCallbacks.ts +30 -0
  153. package/src/tui/hooks/usePluginScreenData.ts +84 -0
  154. package/src/tui/hooks/useSideEffects.ts +210 -0
  155. package/src/tui/hooks/useSlashRouting.ts +117 -0
  156. package/src/tui/hooks/useStatusLineSettings.ts +35 -0
  157. package/src/tui/index.ts +3 -0
  158. package/src/tui/plugin-tui-handlers.ts +163 -0
  159. package/src/tui/render-markdown.ts +129 -0
  160. package/src/tui/render.tsx +60 -0
  161. package/src/tui/status-activity.ts +63 -0
  162. package/src/tui/tui-cli-adapter-context.tsx +12 -0
  163. package/src/tui/tui-cli-adapter.ts +25 -0
  164. package/src/tui/tui-state-manager.ts +225 -0
  165. package/src/tui/tui-transport.ts +32 -0
  166. package/src/tui/types.ts +14 -0
  167. package/src/tui/utils/__tests__/edit-diff.test.ts +426 -0
  168. package/src/tui/utils/__tests__/paste-detection.test.ts +116 -0
  169. package/src/tui/utils/__tests__/paste-labels.test.ts +46 -0
  170. package/src/tui/utils/__tests__/tool-call-extractor.test.ts +227 -0
  171. package/src/tui/utils/__tests__/tool-diff-summary.test.ts +104 -0
  172. package/src/tui/utils/edit-diff.ts +152 -0
  173. package/src/tui/utils/paste-labels.ts +9 -0
  174. package/src/tui/utils/tool-call-extractor.ts +91 -0
  175. package/src/tui/utils/tool-diff-summary.ts +75 -0
  176. package/src/ws/__tests__/ws-handler.test.ts +407 -0
  177. package/src/ws/__tests__/ws-transport.test.ts +53 -0
  178. package/src/ws/index.ts +13 -0
  179. package/src/ws/ws-background-messages.ts +170 -0
  180. package/src/ws/ws-handler.ts +279 -0
  181. package/src/ws/ws-protocol.ts +76 -0
  182. package/src/ws/ws-transport-configurable.ts +123 -0
  183. package/src/ws/ws-transport.ts +42 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * TransportTUI — interactive overlay for transport enable/disable settings.
3
+ *
4
+ * Arrow keys navigate the list, space toggles enabled/disabled, enter/esc closes.
5
+ */
6
+
7
+ import React, { useState, useCallback } from 'react';
8
+ import { Box, Text, useInput } from 'ink';
9
+ import type {
10
+ ITransportEntry,
11
+ ITransportRegistryView,
12
+ } from '@robota-sdk/agent-interface-transport';
13
+ import type { IInteractiveSession } from '@robota-sdk/agent-framework';
14
+
15
+ const TRANSPORT_NAME_WIDTH = 18;
16
+
17
+ interface IEntryRowProps {
18
+ entry: ITransportEntry<IInteractiveSession>;
19
+ selected: boolean;
20
+ }
21
+
22
+ function TransportEntryRow({ entry, selected }: IEntryRowProps): React.ReactElement {
23
+ const enabled = entry.config.enabled;
24
+ const dot = enabled ? '●' : '○';
25
+ const badge = enabled ? '[enabled] ' : '[disabled]';
26
+ const portOpt = entry.config.options?.port;
27
+ const portHint = typeof portOpt === 'number' ? `port: ${portOpt}` : '';
28
+ return (
29
+ <Box>
30
+ <Text color={selected ? 'cyan' : undefined} bold={selected}>
31
+ {`${dot} ${entry.transport.name.padEnd(TRANSPORT_NAME_WIDTH)} ${badge} ${portHint}`}
32
+ </Text>
33
+ </Box>
34
+ );
35
+ }
36
+
37
+ type TKey = { upArrow: boolean; downArrow: boolean; escape: boolean; return: boolean };
38
+
39
+ function useTransportInput(
40
+ entries: ITransportEntry<IInteractiveSession>[],
41
+ cursor: number,
42
+ saving: boolean,
43
+ registry: ITransportRegistryView<IInteractiveSession>,
44
+ setCursor: (fn: (c: number) => number) => void,
45
+ setSaving: (v: boolean) => void,
46
+ onClose: () => void,
47
+ refresh: () => void,
48
+ ): void {
49
+ useInput(
50
+ useCallback(
51
+ (_input: string, key: TKey) => {
52
+ if (saving) return;
53
+ if (key.upArrow) {
54
+ setCursor((c) => Math.max(0, c - 1));
55
+ return;
56
+ }
57
+ if (key.downArrow) {
58
+ setCursor((c) => Math.min(entries.length - 1, c + 1));
59
+ return;
60
+ }
61
+ if (key.escape || key.return) {
62
+ onClose();
63
+ return;
64
+ }
65
+ if (_input === ' ') {
66
+ const entry = entries[cursor];
67
+ if (!entry) return;
68
+ setSaving(true);
69
+ registry
70
+ .setEnabled(entry.transport.name, !entry.config.enabled)
71
+ .then(() => {
72
+ refresh();
73
+ setSaving(false);
74
+ })
75
+ .catch(() => setSaving(false));
76
+ }
77
+ },
78
+ [saving, entries, cursor, registry, onClose, refresh, setCursor, setSaving],
79
+ ),
80
+ );
81
+ }
82
+
83
+ interface IProps {
84
+ registry: ITransportRegistryView<IInteractiveSession>;
85
+ onClose: () => void;
86
+ }
87
+
88
+ export default function TransportTUI({ registry, onClose }: IProps): React.ReactElement {
89
+ const [entries, setEntries] = useState(() => registry.getAll());
90
+ const [cursor, setCursor] = useState(0);
91
+ const [saving, setSaving] = useState(false);
92
+ const refresh = useCallback((): void => {
93
+ setEntries(registry.getAll());
94
+ }, [registry]);
95
+
96
+ useTransportInput(entries, cursor, saving, registry, setCursor, setSaving, onClose, refresh);
97
+
98
+ return (
99
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
100
+ <Text bold>Settings › Transports</Text>
101
+ <Box marginTop={1} flexDirection="column">
102
+ {entries.map((entry, i) => (
103
+ <TransportEntryRow key={entry.transport.name} entry={entry} selected={i === cursor} />
104
+ ))}
105
+ </Box>
106
+ <Box marginTop={1}>
107
+ <Text dimColor>↑↓ select space toggle enter/esc close</Text>
108
+ </Box>
109
+ {saving && (
110
+ <Box marginTop={1}>
111
+ <Text color="yellow">Saving…</Text>
112
+ </Box>
113
+ )}
114
+ </Box>
115
+ );
116
+ }
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface IProps {
5
+ message: string;
6
+ }
7
+
8
+ export default function UpdateNotice({ message }: IProps): React.ReactElement {
9
+ return (
10
+ <Box paddingX={1} marginBottom={1}>
11
+ <Text color="yellow">{message}</Text>
12
+ </Box>
13
+ );
14
+ }
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import type { IHistoryEntry } from '@robota-sdk/agent-core';
4
+ import { formatTokenCount } from '@robota-sdk/agent-core';
5
+ import type { IUsageSnapshot } from '@robota-sdk/agent-framework';
6
+
7
+ const TOKEN_COMPACT_THRESHOLD = 1000;
8
+
9
+ export default function UsageSummaryEntry({ entry }: { entry: IHistoryEntry }): React.ReactElement {
10
+ const usage = entry.data as IUsageSnapshot | undefined;
11
+ if (!usage) return <></>;
12
+ const prompt = usage.promptTokens !== undefined ? formatUsageTokenCount(usage.promptTokens) : '?';
13
+ const completion =
14
+ usage.completionTokens !== undefined ? formatUsageTokenCount(usage.completionTokens) : '?';
15
+ const total = formatUsageTokenCount(usage.totalTokens);
16
+ const context = `${Math.round(usage.contextUsedPercentage)}% (${formatTokenCount(
17
+ usage.contextUsedTokens,
18
+ )}/${formatTokenCount(usage.contextMaxTokens)})`;
19
+ const costLabel = usage.costStatus === 'unknown' ? 'cost unknown' : `cost ${usage.costStatus}`;
20
+
21
+ return (
22
+ <Box flexDirection="column" marginBottom={1}>
23
+ <Box>
24
+ <Text color="white" bold>
25
+ Usage:{' '}
26
+ </Text>
27
+ <Text dimColor>
28
+ {usage.kind} {total} tokens (in {prompt} / out {completion}) · Context {context} ·{' '}
29
+ {costLabel}
30
+ </Text>
31
+ </Box>
32
+ </Box>
33
+ );
34
+ }
35
+
36
+ function formatUsageTokenCount(tokens: number): string {
37
+ return tokens < TOKEN_COMPACT_THRESHOLD ? tokens.toLocaleString() : formatTokenCount(tokens);
38
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * WaveText — renders text with a subtle wave color animation.
3
+ * Groups of 3-4 characters share the same color, creating a soft flowing effect.
4
+ * Colors stay in a narrow range (dim grays) to avoid harsh contrast.
5
+ */
6
+
7
+ import React, { useState, useEffect } from 'react';
8
+ import { Text } from 'ink';
9
+
10
+ // Subtle gray tones — minimal contrast, soft wave
11
+ const WAVE_COLORS = ['#666666', '#888888', '#aaaaaa', '#888888'] as const;
12
+ const INTERVAL_MS = 400;
13
+ const CHARS_PER_GROUP = 4;
14
+
15
+ interface IProps {
16
+ text: string;
17
+ }
18
+
19
+ export default function WaveText({ text }: IProps): React.ReactElement {
20
+ const [tick, setTick] = useState(0);
21
+
22
+ useEffect(() => {
23
+ const timer = setInterval(() => {
24
+ setTick((prev) => prev + 1);
25
+ }, INTERVAL_MS);
26
+ return () => clearInterval(timer);
27
+ }, []);
28
+
29
+ const chars = [...text];
30
+
31
+ return (
32
+ <Text>
33
+ {chars.map((char, i) => {
34
+ const group = Math.floor(i / CHARS_PER_GROUP);
35
+ const colorIndex = (tick + group) % WAVE_COLORS.length;
36
+ return (
37
+ <Text key={i} color={WAVE_COLORS[colorIndex]}>
38
+ {char}
39
+ </Text>
40
+ );
41
+ })}
42
+ </Text>
43
+ );
44
+ }
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import InteractivePrompt from '../InteractivePrompt.js';
5
+
6
+ const delay = () => new Promise((resolve) => setTimeout(resolve, 20));
7
+
8
+ describe('InteractivePrompt', () => {
9
+ it('renders a generic choice prompt and submits the selected value', async () => {
10
+ const onSubmit = vi.fn();
11
+ const { stdin, lastFrame } = render(
12
+ <InteractivePrompt
13
+ prompt={{
14
+ kind: 'choice',
15
+ title: 'Select item',
16
+ options: [
17
+ { value: 'first', label: 'First item' },
18
+ { value: 'second', label: 'Second item' },
19
+ ],
20
+ }}
21
+ onSubmit={onSubmit}
22
+ onCancel={() => {}}
23
+ />,
24
+ );
25
+
26
+ expect(lastFrame()!).toContain('Select item');
27
+ expect(lastFrame()!).toContain('First item');
28
+
29
+ stdin.write('\u001B[B');
30
+ await delay();
31
+ stdin.write('\r');
32
+ await delay();
33
+
34
+ expect(onSubmit).toHaveBeenCalledWith('second');
35
+ });
36
+
37
+ it('renders a generic text prompt and validates submitted values', async () => {
38
+ const onSubmit = vi.fn();
39
+ const { stdin, lastFrame } = render(
40
+ <InteractivePrompt
41
+ prompt={{
42
+ kind: 'text',
43
+ title: 'Secret',
44
+ masked: true,
45
+ validate: (value) => (value.length === 0 ? 'Required' : undefined),
46
+ }}
47
+ onSubmit={onSubmit}
48
+ onCancel={() => {}}
49
+ />,
50
+ );
51
+
52
+ stdin.write('\r');
53
+ await delay();
54
+ expect(onSubmit).not.toHaveBeenCalled();
55
+ expect(lastFrame()!).toContain('Required');
56
+
57
+ stdin.write('abc');
58
+ await delay();
59
+ stdin.write('\r');
60
+ await delay();
61
+ expect(onSubmit).toHaveBeenCalledWith('abc');
62
+ });
63
+
64
+ it('renders generic prompt descriptions without command-specific UI branches', () => {
65
+ const { lastFrame } = render(
66
+ <InteractivePrompt
67
+ prompt={{
68
+ kind: 'text',
69
+ title: 'OpenAI API key',
70
+ description:
71
+ 'Setup help: API key: OpenAI API keys - https://platform.openai.com/api-keys',
72
+ }}
73
+ onSubmit={() => {}}
74
+ onCancel={() => {}}
75
+ />,
76
+ );
77
+
78
+ expect(lastFrame()!).toContain('OpenAI API key');
79
+ expect(lastFrame()!).toContain('OpenAI API keys');
80
+ expect(lastFrame()!).toContain('https://platform.openai.com/api-keys');
81
+ });
82
+ });
@@ -0,0 +1,159 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { Text } from 'ink';
4
+ import { describe, it, expect } from 'vitest';
5
+ import ListPicker from '../ListPicker.js';
6
+
7
+ describe('ListPicker', () => {
8
+ it('renders all items with first selected by default', () => {
9
+ const items = ['Alpha', 'Beta', 'Gamma'];
10
+ const { lastFrame } = render(
11
+ <ListPicker
12
+ items={items}
13
+ renderItem={(item, isSelected) => (
14
+ <Text>
15
+ {isSelected ? '> ' : ' '}
16
+ {item}
17
+ </Text>
18
+ )}
19
+ onSelect={() => {}}
20
+ onCancel={() => {}}
21
+ />,
22
+ );
23
+ const frame = lastFrame()!;
24
+ expect(frame).toContain('> Alpha');
25
+ expect(frame).toContain(' Beta');
26
+ expect(frame).toContain(' Gamma');
27
+ });
28
+
29
+ it('renders empty state when no items', () => {
30
+ const { lastFrame } = render(
31
+ <ListPicker
32
+ items={[]}
33
+ renderItem={(item, isSelected) => (
34
+ <Text>
35
+ {isSelected ? '> ' : ' '}
36
+ {String(item)}
37
+ </Text>
38
+ )}
39
+ onSelect={() => {}}
40
+ onCancel={() => {}}
41
+ />,
42
+ );
43
+ // Should render without crashing
44
+ expect(lastFrame()).toBeDefined();
45
+ });
46
+
47
+ it('calls onSelect with item on Enter', () => {
48
+ let selected = '';
49
+ const items = ['Alpha', 'Beta'];
50
+ const { stdin } = render(
51
+ <ListPicker
52
+ items={items}
53
+ renderItem={(item, isSelected) => (
54
+ <Text>
55
+ {isSelected ? '> ' : ' '}
56
+ {item}
57
+ </Text>
58
+ )}
59
+ onSelect={(item) => {
60
+ selected = item;
61
+ }}
62
+ onCancel={() => {}}
63
+ />,
64
+ );
65
+ stdin.write('\r');
66
+ expect(selected).toBe('Alpha');
67
+ });
68
+
69
+ it('navigates down with arrow key and selects', () => {
70
+ let selected = '';
71
+ const items = ['Alpha', 'Beta', 'Gamma'];
72
+ const { stdin } = render(
73
+ <ListPicker
74
+ items={items}
75
+ renderItem={(item, isSelected) => (
76
+ <Text>
77
+ {isSelected ? '> ' : ' '}
78
+ {item}
79
+ </Text>
80
+ )}
81
+ onSelect={(item) => {
82
+ selected = item;
83
+ }}
84
+ onCancel={() => {}}
85
+ />,
86
+ );
87
+ stdin.write('\x1B[B'); // Down arrow
88
+ stdin.write('\r');
89
+ expect(selected).toBe('Beta');
90
+ });
91
+
92
+ it('calls onCancel on Escape', async () => {
93
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
94
+ let cancelled = false;
95
+ const { stdin } = render(
96
+ <ListPicker
97
+ items={['Alpha']}
98
+ renderItem={(item, isSelected) => (
99
+ <Text>
100
+ {isSelected ? '> ' : ' '}
101
+ {item}
102
+ </Text>
103
+ )}
104
+ onSelect={() => {}}
105
+ onCancel={() => {
106
+ cancelled = true;
107
+ }}
108
+ />,
109
+ );
110
+ stdin.write('\x1B');
111
+ await delay(50);
112
+ expect(cancelled).toBe(true);
113
+ });
114
+
115
+ it('does not go above first item', () => {
116
+ const items = ['Alpha', 'Beta'];
117
+ const { stdin, lastFrame } = render(
118
+ <ListPicker
119
+ items={items}
120
+ renderItem={(item, isSelected) => (
121
+ <Text>
122
+ {isSelected ? '> ' : ' '}
123
+ {item}
124
+ </Text>
125
+ )}
126
+ onSelect={() => {}}
127
+ onCancel={() => {}}
128
+ />,
129
+ );
130
+ stdin.write('\x1B[A'); // Up arrow (already at 0)
131
+ const frame = lastFrame()!;
132
+ expect(frame).toContain('> Alpha');
133
+ });
134
+
135
+ it('does not go below last item', () => {
136
+ let selected = '';
137
+ const items = ['Alpha', 'Beta'];
138
+ const { stdin } = render(
139
+ <ListPicker
140
+ items={items}
141
+ renderItem={(item, isSelected) => (
142
+ <Text>
143
+ {isSelected ? '> ' : ' '}
144
+ {item}
145
+ </Text>
146
+ )}
147
+ onSelect={(item) => {
148
+ selected = item;
149
+ }}
150
+ onCancel={() => {}}
151
+ />,
152
+ );
153
+ stdin.write('\x1B[B'); // Down
154
+ stdin.write('\x1B[B'); // Down (should stay at Beta)
155
+ stdin.write('\x1B[B'); // Down (should stay at Beta)
156
+ stdin.write('\r');
157
+ expect(selected).toBe('Beta');
158
+ });
159
+ });
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect } from 'vitest';
4
+ import MenuSelect from '../MenuSelect.js';
5
+
6
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
7
+
8
+ describe('MenuSelect', () => {
9
+ const items = [
10
+ { label: 'Option A', value: 'a' },
11
+ { label: 'Option B', value: 'b', hint: 'some hint' },
12
+ { label: 'Option C', value: 'c' },
13
+ ];
14
+
15
+ it('renders title and all items', () => {
16
+ const { lastFrame } = render(
17
+ <MenuSelect title="Test Menu" items={items} onSelect={() => {}} onBack={() => {}} />,
18
+ );
19
+ const frame = lastFrame()!;
20
+ expect(frame).toContain('Test Menu');
21
+ expect(frame).toContain('Option A');
22
+ expect(frame).toContain('Option B');
23
+ expect(frame).toContain('Option C');
24
+ });
25
+
26
+ it('renders hint text when provided', () => {
27
+ const { lastFrame } = render(
28
+ <MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
29
+ );
30
+ expect(lastFrame()!).toContain('some hint');
31
+ });
32
+
33
+ it('highlights first item by default with > prefix', () => {
34
+ const { lastFrame } = render(
35
+ <MenuSelect title="Test" items={items} onSelect={() => {}} onBack={() => {}} />,
36
+ );
37
+ expect(lastFrame()!).toContain('>');
38
+ });
39
+
40
+ it('shows loading state', () => {
41
+ const { lastFrame } = render(
42
+ <MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} loading />,
43
+ );
44
+ expect(lastFrame()!).toContain('Loading');
45
+ });
46
+
47
+ it('shows error state', () => {
48
+ const { lastFrame } = render(
49
+ <MenuSelect title="Test" items={[]} onSelect={() => {}} onBack={() => {}} error="Failed" />,
50
+ );
51
+ expect(lastFrame()!).toContain('Failed');
52
+ });
53
+
54
+ it('calls onSelect with value on Enter', () => {
55
+ let selected = '';
56
+ const { stdin } = render(
57
+ <MenuSelect
58
+ title="Test"
59
+ items={items}
60
+ onSelect={(v) => {
61
+ selected = v;
62
+ }}
63
+ onBack={() => {}}
64
+ />,
65
+ );
66
+ stdin.write('\r');
67
+ expect(selected).toBe('a');
68
+ });
69
+
70
+ it('calls onBack on Escape', async () => {
71
+ let backed = false;
72
+ const { stdin } = render(
73
+ <MenuSelect
74
+ title="Test"
75
+ items={items}
76
+ onSelect={() => {}}
77
+ onBack={() => {
78
+ backed = true;
79
+ }}
80
+ />,
81
+ );
82
+ stdin.write('\x1B');
83
+ await delay(50);
84
+ expect(backed).toBe(true);
85
+ });
86
+
87
+ it('navigates down with arrow key', () => {
88
+ let selected = '';
89
+ const { stdin } = render(
90
+ <MenuSelect
91
+ title="Test"
92
+ items={items}
93
+ onSelect={(v) => {
94
+ selected = v;
95
+ }}
96
+ onBack={() => {}}
97
+ />,
98
+ );
99
+ stdin.write('\x1B[B'); // Down arrow
100
+ stdin.write('\r');
101
+ expect(selected).toBe('b');
102
+ });
103
+ });