@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,183 @@
1
+ /**
2
+ * E2E-style test for abort during streaming.
3
+ * Uses ink-testing-library to verify that:
4
+ * 1. Streaming text debounce works (renders batched, not per-delta)
5
+ * 2. ESC during streaming triggers abort
6
+ * 3. Partial text is preserved after abort
7
+ */
8
+
9
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
10
+ import { render } from 'ink-testing-library';
11
+ import { Box, Text, useInput } from 'ink';
12
+ import { describe, it, expect } from 'vitest';
13
+
14
+ /**
15
+ * Minimal streaming component that simulates the debounced onTextDelta pattern.
16
+ * Accepts deltas via a callback ref, renders accumulated text.
17
+ */
18
+ function StreamingTestApp({
19
+ onReady,
20
+ onAbort,
21
+ }: {
22
+ onReady: (appendDelta: (text: string) => void) => void;
23
+ onAbort: () => void;
24
+ }): React.ReactElement {
25
+ const [text, setText] = useState('');
26
+ const textRef = useRef('');
27
+ const [aborted, setAborted] = useState(false);
28
+ const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+ const renderCountRef = useRef(0);
30
+
31
+ // Debounced delta handler (same pattern as InteractiveSession)
32
+ const appendDelta = useCallback((delta: string) => {
33
+ textRef.current += delta;
34
+ if (!flushTimerRef.current) {
35
+ flushTimerRef.current = setTimeout(() => {
36
+ setText(textRef.current);
37
+ flushTimerRef.current = null;
38
+ }, 16);
39
+ }
40
+ }, []);
41
+
42
+ // ESC handler
43
+ useInput((_input, key) => {
44
+ if (key.escape) {
45
+ setAborted(true);
46
+ onAbort();
47
+ // Force flush any pending text
48
+ if (flushTimerRef.current) {
49
+ clearTimeout(flushTimerRef.current);
50
+ flushTimerRef.current = null;
51
+ }
52
+ setText(textRef.current);
53
+ }
54
+ });
55
+
56
+ // Notify parent that we're ready
57
+ useEffect(() => {
58
+ onReady(appendDelta);
59
+ }, [onReady, appendDelta]);
60
+
61
+ renderCountRef.current++;
62
+
63
+ return (
64
+ <Box flexDirection="column">
65
+ <Text>{text}</Text>
66
+ {aborted && <Text color="yellow">Interrupted by user.</Text>}
67
+ <Text dimColor>renders: {renderCountRef.current}</Text>
68
+ </Box>
69
+ );
70
+ }
71
+
72
+ describe('Streaming abort E2E', () => {
73
+ it('debounced streaming renders fewer times than delta count', async () => {
74
+ let appendDelta: ((text: string) => void) | null = null;
75
+
76
+ const { lastFrame } = render(
77
+ React.createElement(StreamingTestApp, {
78
+ onReady: (fn: (text: string) => void) => {
79
+ appendDelta = fn;
80
+ },
81
+ onAbort: () => {},
82
+ }),
83
+ );
84
+
85
+ // Wait for component to mount
86
+ await new Promise((r) => setTimeout(r, 20));
87
+ expect(appendDelta).not.toBeNull();
88
+
89
+ // Send 20 rapid deltas
90
+ for (let i = 0; i < 20; i++) {
91
+ appendDelta!(`chunk${i} `);
92
+ }
93
+
94
+ // Wait for debounce flush
95
+ await new Promise((r) => setTimeout(r, 50));
96
+
97
+ const frame = lastFrame()!;
98
+ // All text should be present
99
+ expect(frame).toContain('chunk0');
100
+ expect(frame).toContain('chunk19');
101
+
102
+ // Render count should be MUCH less than 20 (debounced)
103
+ const renderMatch = frame.match(/renders: (\d+)/);
104
+ expect(renderMatch).not.toBeNull();
105
+ const renderCount = parseInt(renderMatch![1], 10);
106
+ // With 16ms debounce and ~50ms total time, expect 3-5 renders, not 20+
107
+ expect(renderCount).toBeLessThan(10);
108
+ });
109
+
110
+ it('ESC during rapid streaming triggers abort and shows text', async () => {
111
+ let appendDelta: ((text: string) => void) | null = null;
112
+ let abortCalled = false;
113
+
114
+ const { stdin, lastFrame } = render(
115
+ React.createElement(StreamingTestApp, {
116
+ onReady: (fn: (text: string) => void) => {
117
+ appendDelta = fn;
118
+ },
119
+ onAbort: () => {
120
+ abortCalled = true;
121
+ },
122
+ }),
123
+ );
124
+
125
+ await new Promise((r) => setTimeout(r, 20));
126
+
127
+ // Send some deltas
128
+ for (let i = 0; i < 5; i++) {
129
+ appendDelta!(`line${i} `);
130
+ }
131
+
132
+ // Press ESC
133
+ stdin.write('\x1B');
134
+ await new Promise((r) => setTimeout(r, 50));
135
+
136
+ expect(abortCalled).toBe(true);
137
+
138
+ const frame = lastFrame()!;
139
+ // Text should be visible (flush on abort)
140
+ expect(frame).toContain('line0');
141
+ expect(frame).toContain('line4');
142
+ // Cancelled indicator
143
+ expect(frame).toContain('Interrupted by user.');
144
+ });
145
+
146
+ it('ESC during ongoing streaming stops further rendering', async () => {
147
+ let appendDelta: ((text: string) => void) | null = null;
148
+ let abortCalled = false;
149
+
150
+ const { stdin, lastFrame } = render(
151
+ React.createElement(StreamingTestApp, {
152
+ onReady: (fn: (text: string) => void) => {
153
+ appendDelta = fn;
154
+ },
155
+ onAbort: () => {
156
+ abortCalled = true;
157
+ },
158
+ }),
159
+ );
160
+
161
+ await new Promise((r) => setTimeout(r, 20));
162
+
163
+ // Send first batch
164
+ appendDelta!('before_abort ');
165
+
166
+ // Wait for flush
167
+ await new Promise((r) => setTimeout(r, 20));
168
+
169
+ // Press ESC
170
+ stdin.write('\x1B');
171
+ await new Promise((r) => setTimeout(r, 30));
172
+
173
+ expect(abortCalled).toBe(true);
174
+
175
+ // Send more deltas AFTER abort (should still accumulate in ref but component should show Cancelled)
176
+ appendDelta!('after_abort ');
177
+ await new Promise((r) => setTimeout(r, 50));
178
+
179
+ const frame = lastFrame()!;
180
+ expect(frame).toContain('before_abort');
181
+ expect(frame).toContain('Interrupted by user.');
182
+ });
183
+ });
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import type { IExecutionWorkspaceEntry } from '@robota-sdk/agent-framework';
5
+ import BackgroundTaskPanel from '../BackgroundTaskPanel.js';
6
+
7
+ function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
8
+ return {
9
+ id: 'task:agent_1',
10
+ sourceId: 'agent_1',
11
+ kind: 'background_task',
12
+ origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'agent' },
13
+ taskKind: 'agent',
14
+ status: 'running',
15
+ title: 'general-purpose',
16
+ subtitle: 'agent',
17
+ preview: 'Analyze backlog',
18
+ unread: false,
19
+ attention: 'none',
20
+ visibility: 'default',
21
+ updatedAt: '2026-05-09T00:00:00.000Z',
22
+ controls: ['select', 'cancel'],
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ describe('BackgroundTaskPanel', () => {
28
+ it('renders SDK workspace entries with compact markers instead of raw task ids', () => {
29
+ const { lastFrame } = render(
30
+ <BackgroundTaskPanel
31
+ entries={[
32
+ makeEntry({ id: 'task:agent_1', status: 'running' }),
33
+ makeEntry({ id: 'task:agent_2', status: 'completed', preview: 'Done' }),
34
+ makeEntry({
35
+ id: 'task:agent_3',
36
+ status: 'failed',
37
+ attention: 'failed',
38
+ preview: 'Timed out',
39
+ }),
40
+ ]}
41
+ />,
42
+ );
43
+
44
+ const frame = lastFrame()!;
45
+ expect(frame).toContain('Background work');
46
+ expect(frame).toContain('├ □ general-purpose agent');
47
+ expect(frame).toContain('├ ■ general-purpose agent · completed');
48
+ expect(frame).toContain('└ ■ general-purpose agent · failed');
49
+ expect(frame).not.toContain('agent_1');
50
+ expect(frame).not.toContain('agent_2');
51
+ expect(frame).not.toContain('agent_3');
52
+ });
53
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IExecutionWorkspaceEntry } from '@robota-sdk/agent-framework';
3
+ import { formatBackgroundTaskRow } from '../background-task-row-format.js';
4
+
5
+ function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
6
+ return {
7
+ id: 'task:agent_1',
8
+ sourceId: 'agent_1',
9
+ kind: 'background_task',
10
+ origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'agent' },
11
+ taskKind: 'agent',
12
+ status: 'running',
13
+ title: 'Explore',
14
+ subtitle: 'general-purpose',
15
+ preview: 'Analyze backlog',
16
+ unread: false,
17
+ attention: 'none',
18
+ visibility: 'default',
19
+ updatedAt: '2026-05-09T00:00:00.000Z',
20
+ controls: ['select', 'cancel'],
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe('formatBackgroundTaskRow', () => {
26
+ it('formats running SDK workspace entries without raw task ids', () => {
27
+ const row = formatBackgroundTaskRow(makeEntry({ id: 'task:agent_1' }), { isLast: true });
28
+
29
+ expect(row.connector).toBe('└');
30
+ expect(row.marker).toBe('□');
31
+ expect(row.label).toBe('Explore agent');
32
+ expect(row.segments).toEqual(['running', 'agent · general-purpose']);
33
+ expect(row.preview).toBe('Analyze backlog');
34
+ expect(row.accessibleText).not.toContain('agent_1');
35
+ });
36
+
37
+ it('formats failed and completed rows from SDK-owned status and attention', () => {
38
+ const failed = formatBackgroundTaskRow(
39
+ makeEntry({
40
+ id: 'task:agent_2',
41
+ status: 'failed',
42
+ attention: 'failed',
43
+ preview: 'Timed out',
44
+ }),
45
+ { isLast: false },
46
+ );
47
+ const completed = formatBackgroundTaskRow(
48
+ makeEntry({ id: 'task:agent_3', status: 'completed', preview: 'Summary ready' }),
49
+ );
50
+
51
+ expect(failed.connector).toBe('├');
52
+ expect(failed.marker).toBe('■');
53
+ expect(failed.color).toBe('red');
54
+ expect(failed.preview).toBe('Timed out');
55
+ expect(completed.marker).toBe('■');
56
+ expect(completed.color).toBe('green');
57
+ expect(completed.preview).toBe('Summary ready');
58
+ });
59
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ applyCjkTextInput,
4
+ applyCjkTextPaste,
5
+ createCjkTextInputFlowState,
6
+ syncCjkTextInputFlowState,
7
+ } from '../flows/cjk-text-input-flow.js';
8
+
9
+ describe('cjk text input flow', () => {
10
+ it('Given printable input When applied Then value changes at cursor', () => {
11
+ const result = applyCjkTextInput(
12
+ createCjkTextInputFlowState('ab'),
13
+ 'c',
14
+ {},
15
+ { canPaste: true },
16
+ );
17
+
18
+ expect(result.state).toMatchObject({ value: 'abc', cursor: 3 });
19
+ expect(result.effect).toEqual({ type: 'change', value: 'abc' });
20
+ });
21
+
22
+ it('Given cursor in middle When backspace is applied Then previous char is removed', () => {
23
+ const state = { ...createCjkTextInputFlowState('abc'), cursor: 2 };
24
+
25
+ const result = applyCjkTextInput(state, '', { backspace: true }, { canPaste: true });
26
+
27
+ expect(result.state).toMatchObject({ value: 'ac', cursor: 1 });
28
+ expect(result.effect).toEqual({ type: 'change', value: 'ac' });
29
+ });
30
+
31
+ it('Given return key When applied Then submit effect contains current value', () => {
32
+ const result = applyCjkTextInput(
33
+ createCjkTextInputFlowState('hello'),
34
+ '',
35
+ { return: true },
36
+ { canPaste: true },
37
+ );
38
+
39
+ expect(result.effect).toEqual({ type: 'submit', value: 'hello' });
40
+ });
41
+
42
+ it('Given multiline fallback paste When applied Then paste effect is emitted', () => {
43
+ const result = applyCjkTextInput(
44
+ createCjkTextInputFlowState(''),
45
+ 'a\nb',
46
+ {},
47
+ { canPaste: true },
48
+ );
49
+
50
+ expect(result.effect).toEqual({ type: 'paste', text: 'a\nb', cursor: 0 });
51
+ });
52
+
53
+ it('Given Ink usePaste single-line text When applied Then text is inserted at cursor', () => {
54
+ const state = { ...createCjkTextInputFlowState('ab'), cursor: 1 };
55
+
56
+ const result = applyCjkTextPaste(state, '한글', { canPaste: true });
57
+
58
+ expect(result.state).toMatchObject({ value: 'a한글b', cursor: 3 });
59
+ expect(result.effect).toEqual({ type: 'change', value: 'a한글b' });
60
+ });
61
+
62
+ it('Given Ink usePaste multiline text When applied Then normalized paste effect is emitted', () => {
63
+ const state = { ...createCjkTextInputFlowState('ab'), cursor: 1 };
64
+
65
+ const result = applyCjkTextPaste(state, 'x\r\ny\rz', { canPaste: true });
66
+
67
+ expect(result.state).toBe(state);
68
+ expect(result.effect).toEqual({ type: 'paste', text: 'x\ny\nz', cursor: 1 });
69
+ });
70
+
71
+ it('Given bracketed multiline paste When end marker arrives Then buffered paste is emitted', () => {
72
+ const started = applyCjkTextInput(
73
+ createCjkTextInputFlowState('x'),
74
+ '[200~hello',
75
+ {},
76
+ { canPaste: true },
77
+ ).state;
78
+ const result = applyCjkTextInput(started, '\nworld[201~', {}, { canPaste: true });
79
+
80
+ expect(result.effect).toEqual({ type: 'paste', text: 'hello\nworld', cursor: 1 });
81
+ expect(result.state.isPasting).toBe(false);
82
+ });
83
+
84
+ it('Given external value update When synced Then cursor hint is honored', () => {
85
+ const state = createCjkTextInputFlowState('abc');
86
+
87
+ const result = syncCjkTextInputFlowState(state, 'a[Pasted]bc', 9);
88
+
89
+ expect(result).toMatchObject({ value: 'a[Pasted]bc', cursor: 9 });
90
+ });
91
+
92
+ it('Given vertical navigation disabled When up arrow is applied Then cursor is unchanged', () => {
93
+ const state = { ...createCjkTextInputFlowState('abcdef'), cursor: 5 };
94
+
95
+ const result = applyCjkTextInput(
96
+ state,
97
+ '',
98
+ { upArrow: true },
99
+ {
100
+ canPaste: true,
101
+ enableVerticalNavigation: false,
102
+ availableWidth: 2,
103
+ },
104
+ );
105
+
106
+ expect(result.state).toBe(state);
107
+ expect(result.effect).toEqual({ type: 'none' });
108
+ });
109
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Tests for CjkTextInput pure utility functions.
3
+ *
4
+ * These test the input filtering and insertion logic extracted from
5
+ * the React component, without requiring Ink/React rendering.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import {
10
+ filterPrintable,
11
+ insertAtCursor,
12
+ displayOffset,
13
+ charIndexAtDisplayOffset,
14
+ } from '../flows/cjk-text-input-flow.js';
15
+
16
+ describe('filterPrintable', () => {
17
+ it('returns empty string for null input', () => {
18
+ expect(filterPrintable(null)).toBe('');
19
+ });
20
+
21
+ it('returns empty string for undefined input', () => {
22
+ expect(filterPrintable(undefined)).toBe('');
23
+ });
24
+
25
+ it('returns empty string for empty string', () => {
26
+ expect(filterPrintable('')).toBe('');
27
+ });
28
+
29
+ it('passes through normal ASCII text', () => {
30
+ expect(filterPrintable('hello')).toBe('hello');
31
+ });
32
+
33
+ it('passes through Korean characters', () => {
34
+ expect(filterPrintable('안녕하세요')).toBe('안녕하세요');
35
+ });
36
+
37
+ it('passes through emoji', () => {
38
+ expect(filterPrintable('🎉')).toBe('🎉');
39
+ });
40
+
41
+ it('filters out null byte', () => {
42
+ expect(filterPrintable('\x00')).toBe('');
43
+ });
44
+
45
+ it('filters out escape character', () => {
46
+ expect(filterPrintable('\x1b')).toBe('');
47
+ });
48
+
49
+ it('filters out DEL character', () => {
50
+ expect(filterPrintable('\x7f')).toBe('');
51
+ });
52
+
53
+ it('filters out mixed control characters, keeps printable', () => {
54
+ expect(filterPrintable('\x01hello\x02world\x7f')).toBe('helloworld');
55
+ });
56
+
57
+ it('returns empty when input is only control characters', () => {
58
+ expect(filterPrintable('\x00\x01\x02\x03\x1f\x7f')).toBe('');
59
+ });
60
+
61
+ it('handles tab character (0x09) — filtered as control', () => {
62
+ expect(filterPrintable('\t')).toBe('');
63
+ });
64
+
65
+ it('handles newline (0x0a) — filtered as control', () => {
66
+ expect(filterPrintable('\n')).toBe('');
67
+ });
68
+ });
69
+
70
+ describe('insertAtCursor', () => {
71
+ it('inserts at the beginning', () => {
72
+ const result = insertAtCursor('world', 0, 'hello ');
73
+ expect(result.value).toBe('hello world');
74
+ expect(result.cursor).toBe(6);
75
+ });
76
+
77
+ it('inserts at the end', () => {
78
+ const result = insertAtCursor('hello', 5, ' world');
79
+ expect(result.value).toBe('hello world');
80
+ expect(result.cursor).toBe(11);
81
+ });
82
+
83
+ it('inserts in the middle', () => {
84
+ const result = insertAtCursor('helo', 3, 'l');
85
+ expect(result.value).toBe('hello');
86
+ expect(result.cursor).toBe(4);
87
+ });
88
+
89
+ it('inserts into empty string', () => {
90
+ const result = insertAtCursor('', 0, 'abc');
91
+ expect(result.value).toBe('abc');
92
+ expect(result.cursor).toBe(3);
93
+ });
94
+
95
+ it('handles multi-character paste', () => {
96
+ const result = insertAtCursor('ac', 1, 'bb');
97
+ expect(result.value).toBe('abbc');
98
+ expect(result.cursor).toBe(3);
99
+ });
100
+
101
+ it('handles Korean character insertion', () => {
102
+ const result = insertAtCursor('안세요', 1, '녕하');
103
+ expect(result.value).toBe('안녕하세요');
104
+ expect(result.cursor).toBe(3);
105
+ });
106
+
107
+ it('handles emoji insertion', () => {
108
+ const result = insertAtCursor('hello world', 6, '🎉 ');
109
+ expect(result.value).toBe('hello 🎉 world');
110
+ expect(result.cursor).toBe(9);
111
+ });
112
+ });
113
+
114
+ describe('displayOffset', () => {
115
+ it('returns 0 for charIndex 0', () => {
116
+ expect(displayOffset([...'hello'], 0, 10)).toBe(0);
117
+ });
118
+
119
+ it('returns char count for ASCII within one line', () => {
120
+ expect(displayOffset([...'hello'], 3, 10)).toBe(3);
121
+ });
122
+
123
+ it('accumulates across wrap boundary', () => {
124
+ // "abcde" width 3: offset at index 4 = 4 (abc on line 0, de on line 1)
125
+ expect(displayOffset([...'abcde'], 4, 3)).toBe(4);
126
+ });
127
+
128
+ it('cursor before CJK char is at end of previous content', () => {
129
+ // "abcd한" width 5: cursor before "한" is at col 4 on line 0
130
+ // gap happens when "한" renders, not at cursor position
131
+ expect(displayOffset([...'abcd한'], 4, 5)).toBe(4);
132
+ // cursor after "한": 4(gap included) + 2 = 7 → but via offset: gap is at render
133
+ expect(displayOffset([...'abcd한'], 5, 5)).toBe(7);
134
+ });
135
+
136
+ it('handles pure CJK', () => {
137
+ // "한글" width 5: "한"=2, "글"=2 → total 4, fits on one line
138
+ expect(displayOffset([...'한글'], 2, 5)).toBe(4);
139
+ });
140
+
141
+ it('handles CJK wrapping', () => {
142
+ // "한글테" width 5: cursor before "테" is after "글" (offset 4)
143
+ // "테" doesn't fit at col 4, but cursor is BEFORE the wrap
144
+ expect(displayOffset([...'한글테'], 2, 5)).toBe(4);
145
+ // cursor after "테": gap(1) + 2 = offset 7
146
+ expect(displayOffset([...'한글테'], 3, 5)).toBe(7);
147
+ });
148
+ });
149
+
150
+ describe('charIndexAtDisplayOffset', () => {
151
+ it('returns 0 for offset 0', () => {
152
+ expect(charIndexAtDisplayOffset([...'hello'], 0, 10)).toBe(0);
153
+ });
154
+
155
+ it('finds correct index for ASCII', () => {
156
+ expect(charIndexAtDisplayOffset([...'hello'], 3, 10)).toBe(3);
157
+ });
158
+
159
+ it('returns text length when offset exceeds text', () => {
160
+ expect(charIndexAtDisplayOffset([...'hi'], 100, 10)).toBe(2);
161
+ });
162
+
163
+ it('round-trips with displayOffset for ASCII', () => {
164
+ const text = 'abcdefghij';
165
+ const chars = [...text];
166
+ const width = 4;
167
+ for (let i = 0; i <= chars.length; i++) {
168
+ const off = displayOffset(chars, i, width);
169
+ expect(charIndexAtDisplayOffset(chars, off, width)).toBe(i);
170
+ }
171
+ });
172
+
173
+ it('round-trips with displayOffset for CJK', () => {
174
+ const text = '한글테스트';
175
+ const chars = [...text];
176
+ const width = 5;
177
+ for (let i = 0; i <= chars.length; i++) {
178
+ const off = displayOffset(chars, i, width);
179
+ expect(charIndexAtDisplayOffset(chars, off, width)).toBe(i);
180
+ }
181
+ });
182
+
183
+ it('up arrow simulation: offset - width gives previous line position', () => {
184
+ // "abcdef" width 3: line 0 = "abc", line 1 = "def"
185
+ // cursor at index 4 (line 1, col 1) → offset = 4
186
+ // offset - 3 = 1 → charIndex 1 (line 0, col 1)
187
+ const chars = [...'abcdef'];
188
+ const off = displayOffset(chars, 4, 3);
189
+ expect(charIndexAtDisplayOffset(chars, off - 3, 3)).toBe(1);
190
+ });
191
+ });