@abacus-ai/cli 1.106.25007 → 2.0.0-canary.0

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 (200) hide show
  1. package/.oxlintrc.json +8 -0
  2. package/dist/index.mjs +12603 -0
  3. package/package.json +7 -39
  4. package/resources/abacus.ico +0 -0
  5. package/resources/entitlements.plist +9 -0
  6. package/src/__e2e__/README.md +196 -0
  7. package/src/__e2e__/agent-interactions.e2e.test.tsx +61 -0
  8. package/src/__e2e__/cli-commands.e2e.test.tsx +77 -0
  9. package/src/__e2e__/conversation-throttle.e2e.test.ts +453 -0
  10. package/src/__e2e__/conversation.e2e.test.tsx +56 -0
  11. package/src/__e2e__/diff-preview.e2e.test.tsx +3399 -0
  12. package/src/__e2e__/file-creation.e2e.test.tsx +149 -0
  13. package/src/__e2e__/helpers/test-helpers.ts +450 -0
  14. package/src/__e2e__/keyboard-navigation.e2e.test.tsx +34 -0
  15. package/src/__e2e__/llm-models.e2e.test.ts +402 -0
  16. package/src/__e2e__/mcp/mcp-callback-flow.e2e.test.tsx +71 -0
  17. package/src/__e2e__/mcp/mcp-full-app-ui.e2e.test.tsx +167 -0
  18. package/src/__e2e__/mcp/mcp-ui-rendering.e2e.test.tsx +185 -0
  19. package/src/__e2e__/repl.e2e.test.tsx +78 -0
  20. package/src/__e2e__/shell-compatibility.e2e.test.tsx +76 -0
  21. package/src/__e2e__/theme-mcp.e2e.test.tsx +98 -0
  22. package/src/__e2e__/tool-permissions.e2e.test.tsx +66 -0
  23. package/src/args.ts +22 -0
  24. package/src/components/__tests__/react-compiler.test.tsx +78 -0
  25. package/src/components/__tests__/status-indicator.test.tsx +403 -0
  26. package/src/components/composer/__tests__/bash-runner.test.tsx +263 -0
  27. package/src/components/composer/agent-mode-indicator.tsx +63 -0
  28. package/src/components/composer/bash-runner.tsx +54 -0
  29. package/src/components/composer/commands/default-commands.tsx +615 -0
  30. package/src/components/composer/commands/handler.tsx +59 -0
  31. package/src/components/composer/commands/picker.tsx +273 -0
  32. package/src/components/composer/commands/registry.ts +233 -0
  33. package/src/components/composer/commands/types.ts +33 -0
  34. package/src/components/composer/context.tsx +88 -0
  35. package/src/components/composer/file-mention-picker.tsx +83 -0
  36. package/src/components/composer/help.tsx +44 -0
  37. package/src/components/composer/index.tsx +1006 -0
  38. package/src/components/composer/mentions.ts +57 -0
  39. package/src/components/composer/message-queue.tsx +70 -0
  40. package/src/components/composer/mode-panel.tsx +35 -0
  41. package/src/components/composer/modes/__tests__/bash-handler.test.tsx +755 -0
  42. package/src/components/composer/modes/__tests__/bash-renderer.test.tsx +1108 -0
  43. package/src/components/composer/modes/bash-handler.tsx +132 -0
  44. package/src/components/composer/modes/bash-renderer.tsx +175 -0
  45. package/src/components/composer/modes/default-handlers.tsx +33 -0
  46. package/src/components/composer/modes/index.ts +41 -0
  47. package/src/components/composer/modes/types.ts +21 -0
  48. package/src/components/composer/persistent-shell.ts +283 -0
  49. package/src/components/composer/process.ts +65 -0
  50. package/src/components/composer/types.ts +9 -0
  51. package/src/components/composer/use-mention-search.ts +68 -0
  52. package/src/components/error-boundry.tsx +60 -0
  53. package/src/components/exit-message.tsx +29 -0
  54. package/src/components/expanded-view.tsx +74 -0
  55. package/src/components/file-completion.tsx +127 -0
  56. package/src/components/header.tsx +47 -0
  57. package/src/components/logo.tsx +37 -0
  58. package/src/components/segments.tsx +356 -0
  59. package/src/components/status-indicator.tsx +306 -0
  60. package/src/components/tool-group-summary.tsx +263 -0
  61. package/src/components/tool-permissions/ask-user-question-permission-ui.tsx +312 -0
  62. package/src/components/tool-permissions/diff-preview.tsx +355 -0
  63. package/src/components/tool-permissions/index.ts +5 -0
  64. package/src/components/tool-permissions/permission-options.tsx +375 -0
  65. package/src/components/tool-permissions/permission-preview-header.tsx +57 -0
  66. package/src/components/tool-permissions/tool-permission-ui.tsx +398 -0
  67. package/src/components/tools/agent/ask-user-question.tsx +101 -0
  68. package/src/components/tools/agent/enter-plan-mode.tsx +49 -0
  69. package/src/components/tools/agent/exit-plan-mode.tsx +75 -0
  70. package/src/components/tools/agent/handoff-to-main.tsx +27 -0
  71. package/src/components/tools/agent/subagent.tsx +37 -0
  72. package/src/components/tools/agent/todo-write.tsx +104 -0
  73. package/src/components/tools/browser/close-tab.tsx +58 -0
  74. package/src/components/tools/browser/computer.tsx +70 -0
  75. package/src/components/tools/browser/get-interactive-elements.tsx +54 -0
  76. package/src/components/tools/browser/get-tab-content.tsx +51 -0
  77. package/src/components/tools/browser/navigate-to.tsx +59 -0
  78. package/src/components/tools/browser/new-tab.tsx +60 -0
  79. package/src/components/tools/browser/perform-action.tsx +63 -0
  80. package/src/components/tools/browser/refresh-tab.tsx +43 -0
  81. package/src/components/tools/browser/switch-tab.tsx +58 -0
  82. package/src/components/tools/filesystem/delete-file.tsx +104 -0
  83. package/src/components/tools/filesystem/edit.tsx +220 -0
  84. package/src/components/tools/filesystem/list-dir.tsx +78 -0
  85. package/src/components/tools/filesystem/read-file.tsx +180 -0
  86. package/src/components/tools/filesystem/upload-image.tsx +76 -0
  87. package/src/components/tools/ide/ide-diagnostics.tsx +62 -0
  88. package/src/components/tools/index.ts +91 -0
  89. package/src/components/tools/mcp/mcp-tool.tsx +158 -0
  90. package/src/components/tools/search/fetch-url.tsx +73 -0
  91. package/src/components/tools/search/file-search.tsx +78 -0
  92. package/src/components/tools/search/grep.tsx +90 -0
  93. package/src/components/tools/search/semantic-search.tsx +66 -0
  94. package/src/components/tools/search/web-search.tsx +71 -0
  95. package/src/components/tools/shared/index.tsx +48 -0
  96. package/src/components/tools/shared/zod-coercion.ts +35 -0
  97. package/src/components/tools/terminal/bash-tool-output.tsx +174 -0
  98. package/src/components/tools/terminal/get-terminal-output.tsx +85 -0
  99. package/src/components/tools/terminal/run-in-terminal.tsx +106 -0
  100. package/src/components/tools/types.ts +16 -0
  101. package/src/components/tools.tsx +66 -0
  102. package/src/components/ui/__tests__/divider.test.tsx +61 -0
  103. package/src/components/ui/__tests__/gradient.test.tsx +125 -0
  104. package/src/components/ui/__tests__/input.test.tsx +166 -0
  105. package/src/components/ui/__tests__/select.test.tsx +273 -0
  106. package/src/components/ui/__tests__/shimmer.test.tsx +99 -0
  107. package/src/components/ui/blinking-indicator.tsx +25 -0
  108. package/src/components/ui/divider.tsx +162 -0
  109. package/src/components/ui/gradient.tsx +56 -0
  110. package/src/components/ui/input.tsx +228 -0
  111. package/src/components/ui/select.tsx +151 -0
  112. package/src/components/ui/shimmer.tsx +84 -0
  113. package/src/context/agent-mode.tsx +95 -0
  114. package/src/context/extension-file.tsx +136 -0
  115. package/src/context/network-activity.tsx +45 -0
  116. package/src/context/notification.tsx +62 -0
  117. package/src/context/shell-size.tsx +49 -0
  118. package/src/context/shell-title.tsx +38 -0
  119. package/src/entrypoints/print-mode.ts +312 -0
  120. package/src/entrypoints/repl.tsx +401 -0
  121. package/src/hooks/use-agent.ts +15 -0
  122. package/src/hooks/use-api-client.ts +1 -0
  123. package/src/hooks/use-available-height.ts +8 -0
  124. package/src/hooks/use-cleanup.ts +29 -0
  125. package/src/hooks/use-interrupt-manager.ts +242 -0
  126. package/src/hooks/use-models.ts +22 -0
  127. package/src/index.ts +217 -0
  128. package/src/lib/__tests__/ansi.test.ts +255 -0
  129. package/src/lib/__tests__/cli.test.ts +122 -0
  130. package/src/lib/__tests__/commands.test.ts +325 -0
  131. package/src/lib/__tests__/constants.test.ts +15 -0
  132. package/src/lib/__tests__/focusables.test.ts +25 -0
  133. package/src/lib/__tests__/fs.test.ts +231 -0
  134. package/src/lib/__tests__/markdown.test.tsx +348 -0
  135. package/src/lib/__tests__/mcpCommandHandler.test.ts +173 -0
  136. package/src/lib/__tests__/mcpManagement.test.ts +38 -0
  137. package/src/lib/__tests__/path-paste.test.ts +144 -0
  138. package/src/lib/__tests__/path.test.ts +300 -0
  139. package/src/lib/__tests__/queries.test.ts +39 -0
  140. package/src/lib/__tests__/standaloneMcpService.test.ts +71 -0
  141. package/src/lib/__tests__/text-buffer.test.ts +328 -0
  142. package/src/lib/__tests__/text-utils.test.ts +32 -0
  143. package/src/lib/__tests__/timing.test.ts +78 -0
  144. package/src/lib/__tests__/utils.test.ts +238 -0
  145. package/src/lib/__tests__/vim-buffer-actions.test.ts +154 -0
  146. package/src/lib/ansi.ts +150 -0
  147. package/src/lib/cli-push-server.ts +112 -0
  148. package/src/lib/cli.ts +44 -0
  149. package/src/lib/clipboard.ts +226 -0
  150. package/src/lib/command-utils.ts +93 -0
  151. package/src/lib/commands.ts +270 -0
  152. package/src/lib/constants.ts +3 -0
  153. package/src/lib/extension-connection.ts +181 -0
  154. package/src/lib/focusables.ts +7 -0
  155. package/src/lib/fs.ts +533 -0
  156. package/src/lib/markdown/code-block.tsx +63 -0
  157. package/src/lib/markdown/index.ts +4 -0
  158. package/src/lib/markdown/link.tsx +19 -0
  159. package/src/lib/markdown/markdown.tsx +372 -0
  160. package/src/lib/markdown/types.ts +15 -0
  161. package/src/lib/mcpCommandHandler.ts +121 -0
  162. package/src/lib/mcpManagement.ts +44 -0
  163. package/src/lib/path-paste.ts +185 -0
  164. package/src/lib/path.ts +179 -0
  165. package/src/lib/queries.ts +15 -0
  166. package/src/lib/standaloneMcpService.ts +688 -0
  167. package/src/lib/status-utils.ts +237 -0
  168. package/src/lib/test-utils.tsx +72 -0
  169. package/src/lib/text-buffer.ts +2415 -0
  170. package/src/lib/text-utils.ts +272 -0
  171. package/src/lib/timing.ts +63 -0
  172. package/src/lib/types.ts +295 -0
  173. package/src/lib/utils.ts +182 -0
  174. package/src/lib/vim-buffer-actions.ts +732 -0
  175. package/src/providers/agent.tsx +1075 -0
  176. package/src/providers/api-client.tsx +43 -0
  177. package/src/services/logger.ts +85 -0
  178. package/src/terminal/detection.ts +187 -0
  179. package/src/terminal/exit.ts +279 -0
  180. package/src/terminal/notification.ts +83 -0
  181. package/src/terminal/progress.ts +201 -0
  182. package/src/terminal/setup.ts +797 -0
  183. package/src/terminal/suspend.ts +58 -0
  184. package/src/terminal/types.ts +51 -0
  185. package/src/theme/context.tsx +57 -0
  186. package/src/theme/index.ts +4 -0
  187. package/src/theme/themed.tsx +35 -0
  188. package/src/theme/themes.json +546 -0
  189. package/src/theme/types.ts +110 -0
  190. package/src/tools/types.ts +59 -0
  191. package/src/tools/utils/__tests__/zod-coercion.test.ts +33 -0
  192. package/src/tools/utils/tool-ui-components.tsx +631 -0
  193. package/src/tools/utils/zod-coercion.ts +35 -0
  194. package/tsconfig.json +11 -0
  195. package/tsconfig.node.json +29 -0
  196. package/tsconfig.test.json +27 -0
  197. package/tsdown.config.ts +17 -0
  198. package/vitest.config.ts +76 -0
  199. package/README.md +0 -28
  200. package/dist/index.js +0 -26
@@ -0,0 +1,1108 @@
1
+ import { useState, useEffect } from "react";
2
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
3
+
4
+ import { render, cleanup, logInk } from "../../../../lib/test-utils.js";
5
+ import { sleep } from "../../../../lib/utils.js";
6
+ import { BashRenderer, type BashRendererProps } from "../../modes/bash-renderer.js";
7
+ import { destroyPersistentShell } from "../../persistent-shell.js";
8
+
9
+ // Test wrapper component to manage state
10
+ function BashRendererWrapper(
11
+ props: Omit<BashRendererProps, "state" | "setState"> & {
12
+ initialState?: BashRendererProps["state"];
13
+ },
14
+ ) {
15
+ const [state, setState] = useState<BashRendererProps["state"]>(
16
+ props.initialState || {
17
+ submittedCommand: null,
18
+ runId: 0,
19
+ output: "",
20
+ isRunning: false,
21
+ },
22
+ );
23
+
24
+ return <BashRenderer {...props} state={state} setState={setState} />;
25
+ }
26
+
27
+ describe.sequential("BashRenderer", () => {
28
+ const mockSetState = vi.fn();
29
+ const mockOnCommandComplete = vi.fn();
30
+
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ // Clean up any existing shell instance before each test
34
+ destroyPersistentShell();
35
+ });
36
+
37
+ afterEach(() => {
38
+ cleanup();
39
+ // Clean up shell instance after each test
40
+ destroyPersistentShell();
41
+ });
42
+
43
+ it("should render with empty state", () => {
44
+ const instance = render(
45
+ <BashRenderer
46
+ state={{
47
+ submittedCommand: null,
48
+ runId: 0,
49
+ output: "",
50
+ isRunning: false,
51
+ }}
52
+ setState={mockSetState}
53
+ onCommandComplete={mockOnCommandComplete}
54
+ commandHistory={[]}
55
+ currentPrompt=""
56
+ availableHeight={20}
57
+ />,
58
+ );
59
+
60
+ logInk(instance);
61
+
62
+ const output = instance.frames.join("");
63
+ expect(output).toBeDefined();
64
+ });
65
+
66
+ it("should render command history", () => {
67
+ const commandHistory = [
68
+ { command: "echo hello", output: "hello" },
69
+ { command: "ls", output: "file1.txt\nfile2.txt" },
70
+ ];
71
+
72
+ const instance = render(
73
+ <BashRenderer
74
+ state={{
75
+ submittedCommand: null,
76
+ runId: 0,
77
+ output: "",
78
+ isRunning: false,
79
+ }}
80
+ setState={mockSetState}
81
+ onCommandComplete={mockOnCommandComplete}
82
+ commandHistory={commandHistory}
83
+ currentPrompt=""
84
+ availableHeight={20}
85
+ />,
86
+ );
87
+
88
+ logInk(instance);
89
+
90
+ const output = instance.frames.join("");
91
+ expect(output).toContain("echo hello");
92
+ expect(output).toContain("ls");
93
+ expect(output).toContain("hello");
94
+ });
95
+
96
+ it("should render submitted command", () => {
97
+ const instance = render(
98
+ <BashRenderer
99
+ state={{
100
+ submittedCommand: "echo test",
101
+ runId: 1,
102
+ output: "",
103
+ isRunning: true,
104
+ }}
105
+ setState={mockSetState}
106
+ onCommandComplete={mockOnCommandComplete}
107
+ commandHistory={[]}
108
+ currentPrompt=""
109
+ availableHeight={20}
110
+ />,
111
+ );
112
+
113
+ logInk(instance);
114
+
115
+ const output = instance.frames.join("");
116
+ expect(output).toContain("echo test");
117
+ });
118
+
119
+ it("should execute command when submittedCommand is set", async () => {
120
+ render(
121
+ <BashRendererWrapper
122
+ initialState={{
123
+ submittedCommand: 'echo "output line 1\noutput line 2"',
124
+ runId: 1,
125
+ output: "",
126
+ isRunning: false,
127
+ }}
128
+ onCommandComplete={mockOnCommandComplete}
129
+ commandHistory={[]}
130
+ currentPrompt=""
131
+ availableHeight={20}
132
+ />,
133
+ );
134
+
135
+ // Wait for async operations
136
+ await sleep(500);
137
+
138
+ // Command should have been executed (verified by behavior, not mocks)
139
+ expect(mockOnCommandComplete).toHaveBeenCalled();
140
+ });
141
+
142
+ it("should not execute command if normalized is empty", async () => {
143
+ render(
144
+ <BashRenderer
145
+ state={{
146
+ submittedCommand: " ",
147
+ runId: 1,
148
+ output: "",
149
+ isRunning: false,
150
+ }}
151
+ setState={mockSetState}
152
+ onCommandComplete={mockOnCommandComplete}
153
+ commandHistory={[]}
154
+ currentPrompt=""
155
+ availableHeight={20}
156
+ />,
157
+ );
158
+
159
+ await sleep(100);
160
+
161
+ // Empty command should not trigger execution
162
+ expect(mockOnCommandComplete).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it("should execute command when runId is set", async () => {
166
+ render(
167
+ <BashRenderer
168
+ state={{
169
+ submittedCommand: "echo test",
170
+ runId: 1,
171
+ output: "",
172
+ isRunning: false,
173
+ }}
174
+ setState={mockSetState}
175
+ onCommandComplete={mockOnCommandComplete}
176
+ commandHistory={[]}
177
+ currentPrompt=""
178
+ availableHeight={20}
179
+ />,
180
+ );
181
+
182
+ await sleep(500);
183
+
184
+ // The component should execute the command when runId is set
185
+ expect(mockOnCommandComplete).toHaveBeenCalled();
186
+ });
187
+
188
+ it("should not re-execute command with same runId", async () => {
189
+ mockOnCommandComplete.mockClear();
190
+
191
+ // Use a wrapper component that maintains state internally
192
+ // This ensures the same component instance persists, so lastRunIdRef is preserved
193
+ const TestWrapper = () => {
194
+ const [state, setState] = useState<BashRendererProps["state"]>({
195
+ submittedCommand: "echo test1",
196
+ runId: 1,
197
+ output: "",
198
+ isRunning: false,
199
+ });
200
+
201
+ // After first command completes, update output without changing runId
202
+ useEffect(() => {
203
+ if (mockOnCommandComplete.mock.calls.length === 1 && state.output === "") {
204
+ // Simulate parent updating output after command completes
205
+ setTimeout(() => {
206
+ setState((prev) => ({ ...prev, output: "some output" }));
207
+ }, 100);
208
+ }
209
+ }, [state.output]);
210
+
211
+ return (
212
+ <BashRenderer
213
+ state={state}
214
+ setState={setState}
215
+ onCommandComplete={mockOnCommandComplete}
216
+ commandHistory={[]}
217
+ currentPrompt=""
218
+ availableHeight={20}
219
+ />
220
+ );
221
+ };
222
+
223
+ render(<TestWrapper />);
224
+
225
+ // Wait for first execution to complete
226
+ await sleep(600);
227
+
228
+ // Verify command was executed once
229
+ expect(mockOnCommandComplete).toHaveBeenCalledTimes(1);
230
+ const callCountAfterFirstExecution = mockOnCommandComplete.mock.calls.length;
231
+
232
+ // Wait for state update to trigger re-render (output changes but runId stays same)
233
+ await sleep(200);
234
+
235
+ // Wait a bit more to ensure useEffect has run and checked the runId
236
+ await sleep(300);
237
+
238
+ // onCommandComplete should NOT be called again because runId hasn't changed
239
+ // The check `if (runId === lastRunIdRef.current) return` should prevent re-execution
240
+ expect(mockOnCommandComplete).toHaveBeenCalledTimes(callCountAfterFirstExecution);
241
+ });
242
+
243
+ it("should execute command when runId changes", async () => {
244
+ const { rerender } = render(
245
+ <BashRenderer
246
+ state={{
247
+ submittedCommand: "echo test1",
248
+ runId: 1,
249
+ output: "",
250
+ isRunning: false,
251
+ }}
252
+ setState={mockSetState}
253
+ onCommandComplete={mockOnCommandComplete}
254
+ commandHistory={[]}
255
+ currentPrompt=""
256
+ availableHeight={20}
257
+ />,
258
+ );
259
+
260
+ await sleep(300);
261
+
262
+ // Clear previous calls
263
+ mockOnCommandComplete.mockClear();
264
+
265
+ // Re-render with different runId
266
+ rerender(
267
+ <BashRenderer
268
+ state={{
269
+ submittedCommand: "echo test2",
270
+ runId: 2,
271
+ output: "",
272
+ isRunning: false,
273
+ }}
274
+ setState={mockSetState}
275
+ onCommandComplete={mockOnCommandComplete}
276
+ commandHistory={[]}
277
+ currentPrompt=""
278
+ availableHeight={20}
279
+ />,
280
+ );
281
+
282
+ await sleep(500);
283
+
284
+ // Should have called onCommandComplete again with new command
285
+ expect(mockOnCommandComplete).toHaveBeenCalled();
286
+ });
287
+
288
+ it("should update output as stream chunks arrive", async () => {
289
+ const instance = render(
290
+ <BashRendererWrapper
291
+ initialState={{
292
+ submittedCommand: 'echo "chunk1\nchunk2"',
293
+ runId: 1,
294
+ output: "",
295
+ isRunning: false,
296
+ }}
297
+ onCommandComplete={mockOnCommandComplete}
298
+ commandHistory={[]}
299
+ currentPrompt=""
300
+ availableHeight={20}
301
+ />,
302
+ );
303
+
304
+ await sleep(500);
305
+
306
+ // Output should be updated as chunks arrive
307
+ const output = instance.frames.join("");
308
+ expect(output).toBeDefined();
309
+ });
310
+
311
+ it("should strip dangerous sequences from output", async () => {
312
+ // Use a real command - stripDangerousSequences is called internally
313
+ render(
314
+ <BashRendererWrapper
315
+ initialState={{
316
+ submittedCommand: "echo test",
317
+ runId: 1,
318
+ output: "",
319
+ isRunning: false,
320
+ }}
321
+ onCommandComplete={mockOnCommandComplete}
322
+ commandHistory={[]}
323
+ currentPrompt=""
324
+ availableHeight={20}
325
+ />,
326
+ );
327
+
328
+ await sleep(500);
329
+
330
+ // stripDangerousSequences is called internally, we verify behavior
331
+ expect(mockOnCommandComplete).toHaveBeenCalled();
332
+ });
333
+
334
+ it("should handle command execution error gracefully", async () => {
335
+ // Use a command that will fail (non-existent command)
336
+ render(
337
+ <BashRendererWrapper
338
+ initialState={{
339
+ submittedCommand: "nonexistentcommand12345",
340
+ runId: 1,
341
+ output: "",
342
+ isRunning: false,
343
+ }}
344
+ onCommandComplete={mockOnCommandComplete}
345
+ commandHistory={[]}
346
+ currentPrompt=""
347
+ availableHeight={20}
348
+ />,
349
+ );
350
+
351
+ await sleep(500);
352
+
353
+ // Error handling should call onCommandComplete
354
+ expect(mockOnCommandComplete).toHaveBeenCalled();
355
+ });
356
+
357
+ it("should call onCommandComplete with output when command succeeds", async () => {
358
+ render(
359
+ <BashRendererWrapper
360
+ initialState={{
361
+ submittedCommand: 'echo "command output"',
362
+ runId: 1,
363
+ output: "",
364
+ isRunning: false,
365
+ }}
366
+ onCommandComplete={mockOnCommandComplete}
367
+ commandHistory={[]}
368
+ currentPrompt=""
369
+ availableHeight={20}
370
+ />,
371
+ );
372
+
373
+ await sleep(500);
374
+
375
+ expect(mockOnCommandComplete).toHaveBeenCalled();
376
+ // Verify it was called with output (not empty string)
377
+ const calls = mockOnCommandComplete.mock.calls;
378
+ expect(calls.length).toBeGreaterThan(0);
379
+ if (calls.length > 0 && calls[0][0]) {
380
+ expect(calls[0][0].length).toBeGreaterThan(0);
381
+ }
382
+ });
383
+
384
+ it("should set isRunning to true when command starts", async () => {
385
+ render(
386
+ <BashRenderer
387
+ state={{
388
+ submittedCommand: "echo test",
389
+ runId: 1,
390
+ output: "",
391
+ isRunning: false,
392
+ }}
393
+ setState={mockSetState}
394
+ onCommandComplete={mockOnCommandComplete}
395
+ commandHistory={[]}
396
+ currentPrompt=""
397
+ availableHeight={20}
398
+ />,
399
+ );
400
+
401
+ await sleep(50);
402
+
403
+ // setState is called with a function, so we check if it was called
404
+ expect(mockSetState).toHaveBeenCalled();
405
+ // Verify it's called with a function that returns isRunning: true
406
+ const setStateCall = mockSetState.mock.calls.find((call) => {
407
+ if (typeof call[0] === "function") {
408
+ const result = call[0]({ isRunning: false });
409
+ return result.isRunning === true;
410
+ }
411
+ return false;
412
+ });
413
+ expect(setStateCall).toBeDefined();
414
+ });
415
+
416
+ it("should set isRunning to false when command completes", async () => {
417
+ render(
418
+ <BashRenderer
419
+ state={{
420
+ submittedCommand: "echo test",
421
+ runId: 1,
422
+ output: "",
423
+ isRunning: false,
424
+ }}
425
+ setState={mockSetState}
426
+ onCommandComplete={mockOnCommandComplete}
427
+ commandHistory={[]}
428
+ currentPrompt=""
429
+ availableHeight={20}
430
+ />,
431
+ );
432
+
433
+ await sleep(500);
434
+
435
+ // setState is called with a function, verify it sets isRunning to false
436
+ expect(mockSetState).toHaveBeenCalled();
437
+ const setStateCall = mockSetState.mock.calls.find((call) => {
438
+ if (typeof call[0] === "function") {
439
+ const result = call[0]({ isRunning: true });
440
+ return result.isRunning === false;
441
+ }
442
+ return false;
443
+ });
444
+ expect(setStateCall).toBeDefined();
445
+ });
446
+
447
+ it("should limit history display when submittedCommand exists", () => {
448
+ const commandHistory = [
449
+ { command: "cmd1", output: "out1" },
450
+ { command: "cmd2", output: "out2" },
451
+ { command: "cmd3", output: "out3" },
452
+ { command: "cmd4", output: "out4" },
453
+ ];
454
+
455
+ const instance = render(
456
+ <BashRenderer
457
+ state={{
458
+ submittedCommand: "current",
459
+ runId: 1,
460
+ output: "",
461
+ isRunning: false,
462
+ }}
463
+ setState={mockSetState}
464
+ onCommandComplete={mockOnCommandComplete}
465
+ commandHistory={commandHistory}
466
+ currentPrompt=""
467
+ availableHeight={20}
468
+ />,
469
+ );
470
+
471
+ logInk(instance);
472
+
473
+ const output = instance.frames.join("");
474
+ // Should show only last 2 history items when submittedCommand exists
475
+ expect(output).toContain("cmd3");
476
+ expect(output).toContain("cmd4");
477
+ expect(output).not.toContain("cmd1");
478
+ expect(output).not.toContain("cmd2");
479
+ });
480
+
481
+ it("should show more history when no submittedCommand", () => {
482
+ const commandHistory = [
483
+ { command: "cmd1", output: "out1" },
484
+ { command: "cmd2", output: "out2" },
485
+ { command: "cmd3", output: "out3" },
486
+ ];
487
+
488
+ const instance = render(
489
+ <BashRenderer
490
+ state={{
491
+ submittedCommand: null,
492
+ runId: 0,
493
+ output: "",
494
+ isRunning: false,
495
+ }}
496
+ setState={mockSetState}
497
+ onCommandComplete={mockOnCommandComplete}
498
+ commandHistory={commandHistory}
499
+ currentPrompt=""
500
+ availableHeight={20}
501
+ />,
502
+ );
503
+
504
+ logInk(instance);
505
+
506
+ const output = instance.frames.join("");
507
+ // Should show last 3 history items when no submittedCommand
508
+ expect(output).toContain("cmd1");
509
+ expect(output).toContain("cmd2");
510
+ expect(output).toContain("cmd3");
511
+ });
512
+
513
+ it("should calculate maxLinesPerCommand based on available height", () => {
514
+ const commandHistory = [
515
+ { command: "cmd1", output: "out1" },
516
+ { command: "cmd2", output: "out2" },
517
+ ];
518
+
519
+ const instance = render(
520
+ <BashRenderer
521
+ state={{
522
+ submittedCommand: "current",
523
+ runId: 1,
524
+ output: "",
525
+ isRunning: false,
526
+ }}
527
+ setState={mockSetState}
528
+ onCommandComplete={mockOnCommandComplete}
529
+ commandHistory={commandHistory}
530
+ currentPrompt=""
531
+ availableHeight={10}
532
+ />,
533
+ );
534
+
535
+ logInk(instance);
536
+
537
+ const output = instance.frames.join("");
538
+ expect(output).toBeDefined();
539
+ });
540
+
541
+ it("should enable truncation when height is limited", () => {
542
+ const commandHistory = [{ command: "cmd1", output: "line1\nline2\nline3\nline4\nline5" }];
543
+
544
+ const instance = render(
545
+ <BashRenderer
546
+ state={{
547
+ submittedCommand: null,
548
+ runId: 0,
549
+ output: "",
550
+ isRunning: false,
551
+ }}
552
+ setState={mockSetState}
553
+ onCommandComplete={mockOnCommandComplete}
554
+ commandHistory={commandHistory}
555
+ currentPrompt=""
556
+ availableHeight={5}
557
+ />,
558
+ );
559
+
560
+ logInk(instance);
561
+
562
+ const output = instance.frames.join("");
563
+ expect(output).toBeDefined();
564
+ });
565
+
566
+ it("should enable truncation when typing", () => {
567
+ const commandHistory = [{ command: "cmd1", output: "line1\nline2\nline3\nline4\nline5" }];
568
+
569
+ const instance = render(
570
+ <BashRenderer
571
+ state={{
572
+ submittedCommand: null,
573
+ runId: 0,
574
+ output: "",
575
+ isRunning: false,
576
+ }}
577
+ setState={mockSetState}
578
+ onCommandComplete={mockOnCommandComplete}
579
+ commandHistory={commandHistory}
580
+ currentPrompt="typing..."
581
+ availableHeight={20}
582
+ />,
583
+ );
584
+
585
+ logInk(instance);
586
+
587
+ const output = instance.frames.join("");
588
+ expect(output).toBeDefined();
589
+ });
590
+
591
+ it("should enable truncation when multiple commands exist", () => {
592
+ const commandHistory = [
593
+ { command: "cmd1", output: "out1" },
594
+ { command: "cmd2", output: "out2" },
595
+ ];
596
+
597
+ const instance = render(
598
+ <BashRenderer
599
+ state={{
600
+ submittedCommand: "cmd3",
601
+ runId: 1,
602
+ output: "",
603
+ isRunning: false,
604
+ }}
605
+ setState={mockSetState}
606
+ onCommandComplete={mockOnCommandComplete}
607
+ commandHistory={commandHistory}
608
+ currentPrompt=""
609
+ availableHeight={20}
610
+ />,
611
+ );
612
+
613
+ logInk(instance);
614
+
615
+ const output = instance.frames.join("");
616
+ expect(output).toBeDefined();
617
+ });
618
+
619
+ it("should handle empty command history", () => {
620
+ const instance = render(
621
+ <BashRenderer
622
+ state={{
623
+ submittedCommand: "test",
624
+ runId: 1,
625
+ output: "",
626
+ isRunning: false,
627
+ }}
628
+ setState={mockSetState}
629
+ onCommandComplete={mockOnCommandComplete}
630
+ commandHistory={[]}
631
+ currentPrompt=""
632
+ availableHeight={20}
633
+ />,
634
+ );
635
+
636
+ logInk(instance);
637
+
638
+ const output = instance.frames.join("");
639
+ expect(output).toContain("test");
640
+ expect(output).toBeDefined();
641
+ });
642
+
643
+ it("should trim submittedCommand before executing", async () => {
644
+ render(
645
+ <BashRendererWrapper
646
+ initialState={{
647
+ submittedCommand: " echo trimmed-test ",
648
+ runId: 1,
649
+ output: "",
650
+ isRunning: false,
651
+ }}
652
+ onCommandComplete={mockOnCommandComplete}
653
+ commandHistory={[]}
654
+ currentPrompt=""
655
+ availableHeight={20}
656
+ />,
657
+ );
658
+
659
+ await sleep(500);
660
+
661
+ // Command should be executed with trimmed value
662
+ expect(mockOnCommandComplete).toHaveBeenCalled();
663
+ });
664
+
665
+ it("should handle very small availableHeight", () => {
666
+ const instance = render(
667
+ <BashRenderer
668
+ state={{
669
+ submittedCommand: "test",
670
+ runId: 1,
671
+ output: "",
672
+ isRunning: false,
673
+ }}
674
+ setState={mockSetState}
675
+ onCommandComplete={mockOnCommandComplete}
676
+ commandHistory={[]}
677
+ currentPrompt=""
678
+ availableHeight={1}
679
+ />,
680
+ );
681
+
682
+ logInk(instance);
683
+
684
+ const output = instance.frames.join("");
685
+ expect(output).toBeDefined();
686
+ });
687
+
688
+ it("should handle multiple output chunks", async () => {
689
+ render(
690
+ <BashRendererWrapper
691
+ initialState={{
692
+ submittedCommand: 'echo "chunk1chunk2chunk3"',
693
+ runId: 1,
694
+ output: "",
695
+ isRunning: false,
696
+ }}
697
+ onCommandComplete={mockOnCommandComplete}
698
+ commandHistory={[]}
699
+ currentPrompt=""
700
+ availableHeight={20}
701
+ />,
702
+ );
703
+
704
+ await sleep(500);
705
+
706
+ expect(mockOnCommandComplete).toHaveBeenCalled();
707
+ const calls = mockOnCommandComplete.mock.calls;
708
+ if (calls.length > 0 && calls[0][0]) {
709
+ expect(calls[0][0]).toContain("chunk");
710
+ }
711
+ });
712
+
713
+ it("should handle stderr output", async () => {
714
+ // Use a command that produces stderr output
715
+ render(
716
+ <BashRendererWrapper
717
+ initialState={{
718
+ submittedCommand: 'echo "error message" >&2',
719
+ runId: 1,
720
+ output: "",
721
+ isRunning: false,
722
+ }}
723
+ onCommandComplete={mockOnCommandComplete}
724
+ commandHistory={[]}
725
+ currentPrompt=""
726
+ availableHeight={20}
727
+ />,
728
+ );
729
+
730
+ await sleep(500);
731
+
732
+ expect(mockOnCommandComplete).toHaveBeenCalled();
733
+ });
734
+
735
+ it("should handle mixed stdout and stderr", async () => {
736
+ // Use a command that produces both stdout and stderr
737
+ render(
738
+ <BashRendererWrapper
739
+ initialState={{
740
+ submittedCommand: 'echo "stdout" && echo "stderr" >&2',
741
+ runId: 1,
742
+ output: "",
743
+ isRunning: false,
744
+ }}
745
+ onCommandComplete={mockOnCommandComplete}
746
+ commandHistory={[]}
747
+ currentPrompt=""
748
+ availableHeight={20}
749
+ />,
750
+ );
751
+
752
+ await sleep(500);
753
+
754
+ expect(mockOnCommandComplete).toHaveBeenCalled();
755
+ });
756
+
757
+ it("should handle component unmount during command execution", async () => {
758
+ const instance = render(
759
+ <BashRendererWrapper
760
+ initialState={{
761
+ submittedCommand: "sleep 0.1 && echo test",
762
+ runId: 1,
763
+ output: "",
764
+ isRunning: false,
765
+ }}
766
+ onCommandComplete={mockOnCommandComplete}
767
+ commandHistory={[]}
768
+ currentPrompt=""
769
+ availableHeight={20}
770
+ />,
771
+ );
772
+
773
+ logInk(instance);
774
+
775
+ // Unmount before command completes
776
+ cleanup();
777
+
778
+ // Should not crash
779
+ await sleep(200);
780
+ });
781
+
782
+ it("should handle new command submission while previous is running", async () => {
783
+ const { rerender } = render(
784
+ <BashRenderer
785
+ state={{
786
+ submittedCommand: "echo command1",
787
+ runId: 1,
788
+ output: "",
789
+ isRunning: false,
790
+ }}
791
+ setState={mockSetState}
792
+ onCommandComplete={mockOnCommandComplete}
793
+ commandHistory={[]}
794
+ currentPrompt=""
795
+ availableHeight={20}
796
+ />,
797
+ );
798
+
799
+ await sleep(50);
800
+
801
+ // Submit new command before first completes
802
+ rerender(
803
+ <BashRenderer
804
+ state={{
805
+ submittedCommand: "echo command2",
806
+ runId: 2,
807
+ output: "",
808
+ isRunning: false,
809
+ }}
810
+ setState={mockSetState}
811
+ onCommandComplete={mockOnCommandComplete}
812
+ commandHistory={[]}
813
+ currentPrompt=""
814
+ availableHeight={20}
815
+ />,
816
+ );
817
+
818
+ await sleep(500);
819
+
820
+ // Should handle both commands
821
+ expect(mockOnCommandComplete).toHaveBeenCalled();
822
+ });
823
+
824
+ it("should handle many commands with limited height", () => {
825
+ const commandHistory = Array(10)
826
+ .fill(0)
827
+ .map((_, i) => ({
828
+ command: `cmd${i}`,
829
+ output: `out${i}`,
830
+ }));
831
+
832
+ const instance = render(
833
+ <BashRenderer
834
+ state={{
835
+ submittedCommand: "current",
836
+ runId: 1,
837
+ output: "",
838
+ isRunning: false,
839
+ }}
840
+ setState={mockSetState}
841
+ onCommandComplete={mockOnCommandComplete}
842
+ commandHistory={commandHistory}
843
+ currentPrompt=""
844
+ availableHeight={15}
845
+ />,
846
+ );
847
+
848
+ logInk(instance);
849
+
850
+ const output = instance.frames.join("");
851
+ expect(output).toBeDefined();
852
+ });
853
+
854
+ it("should call onCommandComplete with empty string on error", async () => {
855
+ // Use a command that will fail (non-existent command)
856
+ render(
857
+ <BashRendererWrapper
858
+ initialState={{
859
+ submittedCommand: "nonexistentcommand12345",
860
+ runId: 1,
861
+ output: "",
862
+ isRunning: false,
863
+ }}
864
+ onCommandComplete={mockOnCommandComplete}
865
+ commandHistory={[]}
866
+ currentPrompt=""
867
+ availableHeight={20}
868
+ />,
869
+ );
870
+
871
+ await sleep(500);
872
+
873
+ // Error handling should call onCommandComplete
874
+ expect(mockOnCommandComplete).toHaveBeenCalled();
875
+ });
876
+
877
+ it("should reset output when new command starts", async () => {
878
+ const { rerender } = render(
879
+ <BashRenderer
880
+ state={{
881
+ submittedCommand: "echo command1",
882
+ runId: 1,
883
+ output: "old output",
884
+ isRunning: false,
885
+ }}
886
+ setState={mockSetState}
887
+ onCommandComplete={mockOnCommandComplete}
888
+ commandHistory={[]}
889
+ currentPrompt=""
890
+ availableHeight={20}
891
+ />,
892
+ );
893
+
894
+ await sleep(300);
895
+
896
+ // Clear previous calls
897
+ mockSetState.mockClear();
898
+
899
+ // Submit new command with different runId
900
+ rerender(
901
+ <BashRenderer
902
+ state={{
903
+ submittedCommand: "echo command2",
904
+ runId: 2,
905
+ output: "",
906
+ isRunning: false,
907
+ }}
908
+ setState={mockSetState}
909
+ onCommandComplete={mockOnCommandComplete}
910
+ commandHistory={[]}
911
+ currentPrompt=""
912
+ availableHeight={20}
913
+ />,
914
+ );
915
+
916
+ await sleep(500);
917
+
918
+ // Output should be reset for new command (setState is called with isRunning: true first)
919
+ // The output reset happens in the useEffect via setOutput('')
920
+ expect(mockSetState).toHaveBeenCalled();
921
+ });
922
+
923
+ it("should handle command with no output", async () => {
924
+ // Use a command that produces no output (like true or : command)
925
+ render(
926
+ <BashRenderer
927
+ state={{
928
+ submittedCommand: "true",
929
+ runId: 1,
930
+ output: "",
931
+ isRunning: false,
932
+ }}
933
+ setState={mockSetState}
934
+ onCommandComplete={mockOnCommandComplete}
935
+ commandHistory={[]}
936
+ currentPrompt=""
937
+ availableHeight={20}
938
+ />,
939
+ );
940
+
941
+ await sleep(500);
942
+
943
+ // When stream has no chunks, commandOutput remains empty string
944
+ // onCommandComplete should be called with empty string to signal completion
945
+ expect(mockOnCommandComplete).toHaveBeenCalledWith("");
946
+ });
947
+
948
+ it("should handle very long command output", async () => {
949
+ // Use a command that produces long output
950
+ render(
951
+ <BashRendererWrapper
952
+ initialState={{
953
+ submittedCommand: "python3 -c \"print('x' * 10000)\"",
954
+ runId: 1,
955
+ output: "",
956
+ isRunning: false,
957
+ }}
958
+ onCommandComplete={mockOnCommandComplete}
959
+ commandHistory={[]}
960
+ currentPrompt=""
961
+ availableHeight={20}
962
+ />,
963
+ );
964
+
965
+ await sleep(1000);
966
+
967
+ expect(mockOnCommandComplete).toHaveBeenCalled();
968
+ const calls = mockOnCommandComplete.mock.calls;
969
+ if (calls.length > 0 && calls[0][0]) {
970
+ expect(calls[0][0].length).toBeGreaterThan(1000);
971
+ }
972
+ });
973
+
974
+ it("should use normalized command in BashRunner", () => {
975
+ const instance = render(
976
+ <BashRenderer
977
+ state={{
978
+ submittedCommand: " echo hello ",
979
+ runId: 1,
980
+ output: "output",
981
+ isRunning: false,
982
+ }}
983
+ setState={mockSetState}
984
+ onCommandComplete={mockOnCommandComplete}
985
+ commandHistory={[]}
986
+ currentPrompt=""
987
+ availableHeight={20}
988
+ />,
989
+ );
990
+
991
+ logInk(instance);
992
+
993
+ const output = instance.frames.join("");
994
+ // Should show trimmed command
995
+ expect(output).toContain("echo hello");
996
+ expect(output).not.toContain(" echo hello ");
997
+ });
998
+
999
+ it("should handle currentPrompt affecting truncation", () => {
1000
+ const commandHistory = [{ command: "cmd1", output: "line1\nline2\nline3\nline4\nline5" }];
1001
+
1002
+ const instance = render(
1003
+ <BashRenderer
1004
+ state={{
1005
+ submittedCommand: null,
1006
+ runId: 0,
1007
+ output: "",
1008
+ isRunning: false,
1009
+ }}
1010
+ setState={mockSetState}
1011
+ onCommandComplete={mockOnCommandComplete}
1012
+ commandHistory={commandHistory}
1013
+ currentPrompt="typing command..."
1014
+ availableHeight={20}
1015
+ />,
1016
+ );
1017
+
1018
+ logInk(instance);
1019
+
1020
+ const output = instance.frames.join("");
1021
+ expect(output).toBeDefined();
1022
+ });
1023
+
1024
+ it("should not call onCommandComplete when command is cancelled by new command", async () => {
1025
+ mockOnCommandComplete.mockClear();
1026
+
1027
+ const firstCommandCallback = vi.fn();
1028
+ const secondCommandCallback = vi.fn();
1029
+
1030
+ const { rerender } = render(
1031
+ <BashRenderer
1032
+ state={{
1033
+ submittedCommand: "sleep 0.3 && echo command1",
1034
+ runId: 1,
1035
+ output: "",
1036
+ isRunning: false,
1037
+ }}
1038
+ setState={mockSetState}
1039
+ onCommandComplete={firstCommandCallback}
1040
+ commandHistory={[]}
1041
+ currentPrompt=""
1042
+ availableHeight={20}
1043
+ />,
1044
+ );
1045
+
1046
+ // Wait a bit for first command to start
1047
+ await sleep(50);
1048
+
1049
+ // Submit new command before first completes (this should cancel the first)
1050
+ rerender(
1051
+ <BashRenderer
1052
+ state={{
1053
+ submittedCommand: "echo command2",
1054
+ runId: 2,
1055
+ output: "",
1056
+ isRunning: false,
1057
+ }}
1058
+ setState={mockSetState}
1059
+ onCommandComplete={secondCommandCallback}
1060
+ commandHistory={[]}
1061
+ currentPrompt=""
1062
+ availableHeight={20}
1063
+ />,
1064
+ );
1065
+
1066
+ // Wait for both commands to potentially complete
1067
+ await sleep(500);
1068
+
1069
+ // The first command should have been cancelled and not call onCommandComplete
1070
+ expect(firstCommandCallback).not.toHaveBeenCalled();
1071
+ // The second command should have completed and called onCommandComplete
1072
+ expect(secondCommandCallback).toHaveBeenCalled();
1073
+ expect(secondCommandCallback.mock.calls[0][0]).toContain("command2");
1074
+ });
1075
+
1076
+ it("should not call onCommandComplete when component unmounts during execution", async () => {
1077
+ mockOnCommandComplete.mockClear();
1078
+
1079
+ const instance = render(
1080
+ <BashRendererWrapper
1081
+ initialState={{
1082
+ submittedCommand: "sleep 0.2 && echo test",
1083
+ runId: 1,
1084
+ output: "",
1085
+ isRunning: false,
1086
+ }}
1087
+ onCommandComplete={mockOnCommandComplete}
1088
+ commandHistory={[]}
1089
+ currentPrompt=""
1090
+ availableHeight={20}
1091
+ />,
1092
+ );
1093
+
1094
+ logInk(instance);
1095
+
1096
+ // Wait a bit for command to start
1097
+ await sleep(50);
1098
+
1099
+ // Unmount before command completes
1100
+ cleanup();
1101
+
1102
+ // Wait for command to potentially complete
1103
+ await sleep(300);
1104
+
1105
+ // onCommandComplete should not be called for cancelled command
1106
+ expect(mockOnCommandComplete).not.toHaveBeenCalled();
1107
+ });
1108
+ });