@abacus-ai/cli 1.106.25008 → 2.0.0-canary.1

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 (199) hide show
  1. package/.oxlintrc.json +8 -0
  2. package/dist/index.mjs +12823 -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 +449 -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 +1007 -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 +319 -0
  62. package/src/components/tool-permissions/diff-preview.tsx +359 -0
  63. package/src/components/tool-permissions/index.ts +5 -0
  64. package/src/components/tool-permissions/permission-options.tsx +401 -0
  65. package/src/components/tool-permissions/permission-preview-header.tsx +57 -0
  66. package/src/components/tool-permissions/tool-permission-ui.tsx +420 -0
  67. package/src/components/tools/agent/ask-user-question.tsx +107 -0
  68. package/src/components/tools/agent/enter-plan-mode.tsx +55 -0
  69. package/src/components/tools/agent/exit-plan-mode.tsx +83 -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 +188 -0
  98. package/src/components/tools/terminal/get-terminal-output.tsx +91 -0
  99. package/src/components/tools/terminal/run-in-terminal.tsx +131 -0
  100. package/src/components/tools/types.ts +16 -0
  101. package/src/components/tools.tsx +68 -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 +27 -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 +76 -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 +389 -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 +1063 -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/types.ts +51 -0
  184. package/src/theme/context.tsx +57 -0
  185. package/src/theme/index.ts +4 -0
  186. package/src/theme/themed.tsx +35 -0
  187. package/src/theme/themes.json +546 -0
  188. package/src/theme/types.ts +110 -0
  189. package/src/tools/types.ts +59 -0
  190. package/src/tools/utils/__tests__/zod-coercion.test.ts +33 -0
  191. package/src/tools/utils/tool-ui-components.tsx +649 -0
  192. package/src/tools/utils/zod-coercion.ts +35 -0
  193. package/tsconfig.json +16 -0
  194. package/tsconfig.node.json +29 -0
  195. package/tsconfig.test.json +27 -0
  196. package/tsdown.config.ts +17 -0
  197. package/vitest.config.ts +76 -0
  198. package/README.md +0 -28
  199. package/dist/index.js +0 -26
@@ -0,0 +1,403 @@
1
+ // @vitest-environment node
2
+
3
+ import stripAnsi from "strip-ansi";
4
+ import { describe, it, expect, afterEach } from "vitest";
5
+
6
+ import { NetworkActivityProvider } from "../../context/network-activity.js";
7
+ import { render, logInk, cleanup } from "../../lib/test-utils.js";
8
+ import { AgentStatus } from "../../providers/agent.js";
9
+ import { StatusIndicator } from "../status-indicator.js";
10
+
11
+ // Helper function to wrap StatusIndicator with required providers
12
+ function renderStatusIndicator(status: AgentStatus) {
13
+ return render(
14
+ <NetworkActivityProvider>
15
+ <StatusIndicator status={status} />
16
+ </NetworkActivityProvider>,
17
+ );
18
+ }
19
+
20
+ describe.concurrent("StatusIndicator", () => {
21
+ afterEach(() => {
22
+ cleanup();
23
+ });
24
+
25
+ it("should not render when status is Idle", () => {
26
+ const instance = renderStatusIndicator(AgentStatus.Idle);
27
+
28
+ logInk(instance);
29
+
30
+ const output = instance.lastFrame() ?? "";
31
+ // Should be empty or whitespace only when idle
32
+ expect(stripAnsi(output).trim()).toBe("");
33
+ });
34
+
35
+ it("should not render when status is WaitingForToolPermission", () => {
36
+ const instance = renderStatusIndicator(AgentStatus.WaitingForToolPermission);
37
+
38
+ logInk(instance);
39
+
40
+ const output = instance.lastFrame() ?? "";
41
+ expect(stripAnsi(output).trim()).toBe("");
42
+ });
43
+
44
+ it("should render shimmer text when status is Submitted", () => {
45
+ const instance = renderStatusIndicator(AgentStatus.Submitted);
46
+
47
+ logInk(instance);
48
+
49
+ const output = instance.lastFrame() ?? "";
50
+ const plainText = stripAnsi(output);
51
+
52
+ expect(output).toBeDefined();
53
+ // Should show some status text with "..." and escape hint
54
+ expect(plainText).toContain("...");
55
+ expect(plainText).toContain("esc to interrupt");
56
+ });
57
+
58
+ it("should render shimmer text when status is Streaming", () => {
59
+ const instance = renderStatusIndicator(AgentStatus.Streaming);
60
+
61
+ logInk(instance);
62
+
63
+ const output = instance.lastFrame() ?? "";
64
+ const plainText = stripAnsi(output);
65
+
66
+ expect(output).toBeDefined();
67
+ expect(plainText).toContain("...");
68
+ expect(plainText).toContain("esc to interrupt");
69
+ });
70
+
71
+ it("should render shimmer text when status is ExecutingTool", () => {
72
+ const instance = renderStatusIndicator(AgentStatus.ExecutingTool);
73
+
74
+ logInk(instance);
75
+
76
+ const output = instance.lastFrame() ?? "";
77
+ const plainText = stripAnsi(output);
78
+
79
+ expect(output).toBeDefined();
80
+ expect(plainText).toContain("...");
81
+ expect(plainText).toContain("esc to interrupt");
82
+ });
83
+
84
+ it('should render "Resuming" when status is LoadingConversation', () => {
85
+ const instance = renderStatusIndicator(AgentStatus.LoadingConversation);
86
+
87
+ logInk(instance);
88
+
89
+ const output = instance.lastFrame() ?? "";
90
+ const plainText = stripAnsi(output);
91
+
92
+ expect(output).toBeDefined();
93
+ expect(plainText).toContain("Resuming...");
94
+ // Should NOT show escape hint for loading conversation
95
+ expect(plainText).not.toContain("esc to interrupt");
96
+ });
97
+
98
+ it("should show indicator when transitioning from Idle to ExecutingTool", () => {
99
+ // This simulates what happens when an MCP tool starts executing
100
+ // Using rerender() to simulate prop changes from parent (AgentProvider)
101
+ const instance = renderStatusIndicator(AgentStatus.Idle);
102
+
103
+ // Initially idle - should be empty
104
+ let output = instance.lastFrame() ?? "";
105
+ expect(stripAnsi(output).trim()).toBe("");
106
+
107
+ // Simulate MCP tool starting - parent updates status prop
108
+ instance.rerender(
109
+ <NetworkActivityProvider>
110
+ <StatusIndicator status={AgentStatus.ExecutingTool} />
111
+ </NetworkActivityProvider>,
112
+ );
113
+
114
+ // Re-render should show the indicator
115
+ output = instance.lastFrame() ?? "";
116
+ const plainText = stripAnsi(output);
117
+
118
+ console.log("[UI Test] After status change to ExecutingTool:", plainText);
119
+
120
+ expect(plainText).toContain("...");
121
+ expect(plainText).toContain("esc to interrupt");
122
+ });
123
+
124
+ it("should hide indicator when transitioning from ExecutingTool to Idle", () => {
125
+ // This simulates what happens when an MCP tool finishes executing
126
+ const instance = renderStatusIndicator(AgentStatus.ExecutingTool);
127
+
128
+ // Initially executing - should show indicator
129
+ let output = instance.lastFrame() ?? "";
130
+ let plainText = stripAnsi(output);
131
+ expect(plainText).toContain("...");
132
+
133
+ // Simulate MCP tool completing - parent updates status prop
134
+ instance.rerender(
135
+ <NetworkActivityProvider>
136
+ <StatusIndicator status={AgentStatus.Idle} />
137
+ </NetworkActivityProvider>,
138
+ );
139
+
140
+ // Re-render should hide the indicator
141
+ output = instance.lastFrame() ?? "";
142
+ plainText = stripAnsi(output);
143
+
144
+ console.log("[UI Test] After status change to Idle:", `"${plainText}"`);
145
+
146
+ expect(plainText.trim()).toBe("");
147
+ });
148
+
149
+ it("should update immediately when status changes (simulating MCP tool call)", () => {
150
+ // This test verifies that UI updates happen synchronously with status changes
151
+ // If there was a delay bug, this test would catch it
152
+ const instance = renderStatusIndicator(AgentStatus.Idle);
153
+
154
+ const startTime = Date.now();
155
+
156
+ // Simulate rapid status changes like during MCP tool execution
157
+ instance.rerender(
158
+ <NetworkActivityProvider>
159
+ <StatusIndicator status={AgentStatus.Submitted} />
160
+ </NetworkActivityProvider>,
161
+ );
162
+ const afterSubmitted = instance.lastFrame() ?? "";
163
+
164
+ instance.rerender(
165
+ <NetworkActivityProvider>
166
+ <StatusIndicator status={AgentStatus.ExecutingTool} />
167
+ </NetworkActivityProvider>,
168
+ );
169
+ const afterExecuting = instance.lastFrame() ?? "";
170
+
171
+ instance.rerender(
172
+ <NetworkActivityProvider>
173
+ <StatusIndicator status={AgentStatus.Streaming} />
174
+ </NetworkActivityProvider>,
175
+ );
176
+ const afterStreaming = instance.lastFrame() ?? "";
177
+
178
+ instance.rerender(
179
+ <NetworkActivityProvider>
180
+ <StatusIndicator status={AgentStatus.Idle} />
181
+ </NetworkActivityProvider>,
182
+ );
183
+ const afterIdle = instance.lastFrame() ?? "";
184
+
185
+ const totalTime = Date.now() - startTime;
186
+
187
+ console.log("[UI Test] Status transitions:");
188
+ console.log(" - Submitted:", stripAnsi(afterSubmitted).includes("...") ? "visible" : "hidden");
189
+ console.log(
190
+ " - ExecutingTool:",
191
+ stripAnsi(afterExecuting).includes("...") ? "visible" : "hidden",
192
+ );
193
+ console.log(" - Streaming:", stripAnsi(afterStreaming).includes("...") ? "visible" : "hidden");
194
+ console.log(" - Idle:", stripAnsi(afterIdle).trim() === "" ? "hidden" : "visible");
195
+ console.log(` Total time for 4 transitions: ${totalTime}ms`);
196
+
197
+ // Verify each state rendered correctly
198
+ expect(stripAnsi(afterSubmitted)).toContain("...");
199
+ expect(stripAnsi(afterExecuting)).toContain("...");
200
+ expect(stripAnsi(afterStreaming)).toContain("...");
201
+ expect(stripAnsi(afterIdle).trim()).toBe("");
202
+
203
+ // All transitions should be nearly instant (< 50ms total for 4 changes)
204
+ // This catches bugs where UI updates are delayed
205
+ expect(totalTime).toBeLessThan(50);
206
+ });
207
+ });
208
+
209
+ describe.sequential("StatusIndicator - MCP Tool Execution Flow", () => {
210
+ afterEach(() => {
211
+ cleanup();
212
+ });
213
+
214
+ /**
215
+ * This test simulates the exact flow that happens when an MCP tool is called:
216
+ * 1. User sends message -> Submitted
217
+ * 2. Agent starts processing -> Streaming
218
+ * 3. Agent calls MCP tool -> ExecutingTool
219
+ * 4. Tool completes -> Streaming (continues)
220
+ * 5. Agent done -> Idle
221
+ *
222
+ * The bug your teammate fixed was that step 3 (ExecutingTool) wasn't showing
223
+ * in the UI because status updates weren't being handled correctly.
224
+ */
225
+ it("should show correct UI through full MCP tool execution lifecycle", async () => {
226
+ const renderTimes: { status: string; frame: string; time: number; visible: boolean }[] = [];
227
+ const startTime = Date.now();
228
+
229
+ const instance = renderStatusIndicator(AgentStatus.Idle);
230
+
231
+ // Step 0: Initial idle state
232
+ let frame = instance.lastFrame() ?? "";
233
+ renderTimes.push({
234
+ status: "Idle (initial)",
235
+ frame: stripAnsi(frame),
236
+ time: Date.now() - startTime,
237
+ visible: stripAnsi(frame).includes("..."),
238
+ });
239
+
240
+ // Step 1: User submits message
241
+ instance.rerender(
242
+ <NetworkActivityProvider>
243
+ <StatusIndicator status={AgentStatus.Submitted} />
244
+ </NetworkActivityProvider>,
245
+ );
246
+ frame = instance.lastFrame() ?? "";
247
+ renderTimes.push({
248
+ status: "Submitted",
249
+ frame: stripAnsi(frame),
250
+ time: Date.now() - startTime,
251
+ visible: stripAnsi(frame).includes("..."),
252
+ });
253
+
254
+ // Small delay to simulate async behavior
255
+ await new Promise((r) => setTimeout(r, 10));
256
+
257
+ // Step 2: Agent starts streaming response
258
+ instance.rerender(
259
+ <NetworkActivityProvider>
260
+ <StatusIndicator status={AgentStatus.Streaming} />
261
+ </NetworkActivityProvider>,
262
+ );
263
+ frame = instance.lastFrame() ?? "";
264
+ renderTimes.push({
265
+ status: "Streaming",
266
+ frame: stripAnsi(frame),
267
+ time: Date.now() - startTime,
268
+ visible: stripAnsi(frame).includes("..."),
269
+ });
270
+
271
+ await new Promise((r) => setTimeout(r, 10));
272
+
273
+ // Step 3: Agent calls MCP tool - THIS IS THE CRITICAL PART
274
+ // If status update is delayed, user sees no feedback
275
+ instance.rerender(
276
+ <NetworkActivityProvider>
277
+ <StatusIndicator status={AgentStatus.ExecutingTool} />
278
+ </NetworkActivityProvider>,
279
+ );
280
+ frame = instance.lastFrame() ?? "";
281
+ renderTimes.push({
282
+ status: "ExecutingTool",
283
+ frame: stripAnsi(frame),
284
+ time: Date.now() - startTime,
285
+ visible: stripAnsi(frame).includes("..."),
286
+ });
287
+
288
+ await new Promise((r) => setTimeout(r, 10));
289
+
290
+ // Step 4: Tool completes, back to streaming
291
+ instance.rerender(
292
+ <NetworkActivityProvider>
293
+ <StatusIndicator status={AgentStatus.Streaming} />
294
+ </NetworkActivityProvider>,
295
+ );
296
+ frame = instance.lastFrame() ?? "";
297
+ renderTimes.push({
298
+ status: "Streaming (after tool)",
299
+ frame: stripAnsi(frame),
300
+ time: Date.now() - startTime,
301
+ visible: stripAnsi(frame).includes("..."),
302
+ });
303
+
304
+ await new Promise((r) => setTimeout(r, 10));
305
+
306
+ // Step 5: Agent completes
307
+ instance.rerender(
308
+ <NetworkActivityProvider>
309
+ <StatusIndicator status={AgentStatus.Idle} />
310
+ </NetworkActivityProvider>,
311
+ );
312
+ frame = instance.lastFrame() ?? "";
313
+ renderTimes.push({
314
+ status: "Idle (final)",
315
+ frame: stripAnsi(frame),
316
+ time: Date.now() - startTime,
317
+ visible: stripAnsi(frame).includes("..."),
318
+ });
319
+
320
+ // Log the timeline
321
+ console.log("[MCP Flow Test] Render timeline:");
322
+ renderTimes.forEach((r) => {
323
+ console.log(` ${r.time}ms: ${r.status} -> ${r.visible ? "VISIBLE" : "hidden"}`);
324
+ });
325
+
326
+ // Verify we got through all states
327
+ expect(renderTimes.length).toBe(6);
328
+
329
+ // Verify the ExecutingTool state was visible (the bug was this not showing)
330
+ const executingToolEntry = renderTimes.find((r) => r.status === "ExecutingTool");
331
+ expect(executingToolEntry).toBeDefined();
332
+ expect(executingToolEntry?.visible).toBe(true);
333
+
334
+ // Verify Submitted was visible
335
+ const submittedEntry = renderTimes.find((r) => r.status === "Submitted");
336
+ expect(submittedEntry?.visible).toBe(true);
337
+
338
+ // Verify Streaming was visible
339
+ const streamingEntry = renderTimes.find((r) => r.status === "Streaming");
340
+ expect(streamingEntry?.visible).toBe(true);
341
+
342
+ // Verify final Idle is hidden
343
+ const idleFinalEntry = renderTimes.find((r) => r.status === "Idle (final)");
344
+ expect(idleFinalEntry?.visible).toBe(false);
345
+
346
+ // Verify timing - each transition should happen quickly
347
+ for (let i = 1; i < renderTimes.length; i++) {
348
+ const timeBetween = renderTimes[i].time - renderTimes[i - 1].time;
349
+ // Each transition should be < 20ms (plus our 10ms simulated delay)
350
+ expect(timeBetween).toBeLessThanOrEqual(40);
351
+ }
352
+ });
353
+
354
+ /**
355
+ * Test that verifies UI responds to status changes without artificial delays.
356
+ * This would catch bugs where status updates are batched or delayed.
357
+ */
358
+ it("should render each status change immediately without batching", () => {
359
+ const instance = renderStatusIndicator(AgentStatus.Idle);
360
+ const frames: string[] = [];
361
+
362
+ // Rapid-fire status changes
363
+ const statuses: AgentStatus[] = [
364
+ AgentStatus.Submitted,
365
+ AgentStatus.Streaming,
366
+ AgentStatus.ExecutingTool,
367
+ AgentStatus.WaitingForToolPermission,
368
+ AgentStatus.ExecutingTool,
369
+ AgentStatus.Streaming,
370
+ AgentStatus.Idle,
371
+ ];
372
+
373
+ const startTime = Date.now();
374
+
375
+ for (const status of statuses) {
376
+ instance.rerender(
377
+ <NetworkActivityProvider>
378
+ <StatusIndicator status={status} />
379
+ </NetworkActivityProvider>,
380
+ );
381
+ frames.push(instance.lastFrame() ?? "");
382
+ }
383
+
384
+ const totalTime = Date.now() - startTime;
385
+
386
+ console.log(`[Batching Test] ${statuses.length} status changes in ${totalTime}ms`);
387
+ console.log("[Batching Test] Frames captured:", frames.length);
388
+
389
+ // Each status change should produce a frame
390
+ expect(frames.length).toBe(statuses.length);
391
+
392
+ // Should be very fast (no delays between renders)
393
+ expect(totalTime).toBeLessThan(50);
394
+
395
+ // Verify specific states rendered correctly
396
+ // Submitted (index 0) should be visible
397
+ expect(stripAnsi(frames[0])).toContain("...");
398
+ // WaitingForToolPermission (index 3) should be hidden
399
+ expect(stripAnsi(frames[3]).trim()).toBe("");
400
+ // Final Idle (index 6) should be hidden
401
+ expect(stripAnsi(frames[6]).trim()).toBe("");
402
+ });
403
+ });
@@ -0,0 +1,263 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ import * as pathModule from "../../../lib/path.js";
4
+ import { render, cleanup, logInk } from "../../../lib/test-utils.js";
5
+ import { BashRunner } from "../bash-runner.js";
6
+
7
+ // Mock the path module
8
+ vi.mock("../../../lib/path.js", () => ({
9
+ abbreviateHome: vi.fn((path: string) => path),
10
+ }));
11
+
12
+ describe.concurrent("BashRunner", () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ // Default mock implementation
16
+ vi.mocked(pathModule.abbreviateHome).mockImplementation((p: string) => p);
17
+ });
18
+
19
+ afterEach(() => {
20
+ cleanup();
21
+ });
22
+
23
+ it("should render command and output", () => {
24
+ const instance = render(<BashRunner command="echo hello" output="hello" />);
25
+
26
+ logInk(instance);
27
+
28
+ const output = instance.frames.join("");
29
+ expect(output).toContain("echo hello");
30
+ expect(output).toContain("hello");
31
+ });
32
+
33
+ it("should render empty output", () => {
34
+ const instance = render(<BashRunner command="ls" output="" />);
35
+
36
+ logInk(instance);
37
+
38
+ const output = instance.frames.join("");
39
+ expect(output).toContain("ls");
40
+ // Should not crash with empty output
41
+ expect(output).toBeDefined();
42
+ });
43
+
44
+ it("should render multi-line output", () => {
45
+ const multiLineOutput = "line1\nline2\nline3";
46
+ const instance = render(<BashRunner command="cat file.txt" output={multiLineOutput} />);
47
+
48
+ logInk(instance);
49
+
50
+ const output = instance.frames.join("");
51
+ expect(output).toContain("cat file.txt");
52
+ expect(output).toContain("line1");
53
+ expect(output).toContain("line2");
54
+ expect(output).toContain("line3");
55
+ });
56
+
57
+ it("should not truncate when truncate is false", () => {
58
+ const longOutput = Array(20)
59
+ .fill("line")
60
+ .map((_, i) => `line ${i}`)
61
+ .join("\n");
62
+ const instance = render(
63
+ <BashRunner command="long-command" output={longOutput} truncate={false} />,
64
+ );
65
+
66
+ logInk(instance);
67
+
68
+ const output = instance.frames.join("");
69
+ // Should contain all lines when truncate is false
70
+ expect(output).toContain("line 0");
71
+ expect(output).toContain("line 19");
72
+ expect(output).not.toContain("lines hidden");
73
+ });
74
+
75
+ it("should truncate output when truncate is true and output exceeds maxLines", () => {
76
+ const longOutput = Array(20)
77
+ .fill("line")
78
+ .map((_, i) => `line ${i}`)
79
+ .join("\n");
80
+ const instance = render(
81
+ <BashRunner command="long-command" output={longOutput} truncate={true} maxLines={10} />,
82
+ );
83
+
84
+ logInk(instance);
85
+
86
+ const output = instance.frames.join("");
87
+ // Should show first and last half of maxLines
88
+ expect(output).toContain("line 0");
89
+ expect(output).toContain("line 4"); // First half (5 lines)
90
+ expect(output).toContain("line 15"); // Last half (5 lines)
91
+ expect(output).toContain("line 19");
92
+ expect(output).toContain("lines hidden");
93
+ });
94
+
95
+ it("should not truncate when output is within maxLines", () => {
96
+ const shortOutput = Array(5)
97
+ .fill("line")
98
+ .map((_, i) => `line ${i}`)
99
+ .join("\n");
100
+ const instance = render(
101
+ <BashRunner command="short-command" output={shortOutput} truncate={true} maxLines={10} />,
102
+ );
103
+
104
+ logInk(instance);
105
+
106
+ const output = instance.frames.join("");
107
+ expect(output).toContain("line 0");
108
+ expect(output).toContain("line 4");
109
+ expect(output).not.toContain("lines hidden");
110
+ });
111
+
112
+ it("should use default maxLines of 10 when not provided", () => {
113
+ const longOutput = Array(20)
114
+ .fill("line")
115
+ .map((_, i) => `line ${i}`)
116
+ .join("\n");
117
+ const instance = render(<BashRunner command="test" output={longOutput} truncate={true} />);
118
+
119
+ logInk(instance);
120
+
121
+ const output = instance.frames.join("");
122
+ // Should truncate at default 10 lines (5 + 5)
123
+ expect(output).toContain("lines hidden");
124
+ });
125
+
126
+ it("should handle truncation with odd maxLines", () => {
127
+ const longOutput = Array(20)
128
+ .fill("line")
129
+ .map((_, i) => `line ${i}`)
130
+ .join("\n");
131
+ const instance = render(
132
+ <BashRunner command="test" output={longOutput} truncate={true} maxLines={9} />,
133
+ );
134
+
135
+ logInk(instance);
136
+
137
+ const output = instance.frames.join("");
138
+ // With maxLines=9, halfLines=4, so should show first 4 and last 4
139
+ expect(output).toContain("line 0");
140
+ expect(output).toContain("line 3");
141
+ expect(output).toContain("line 16");
142
+ expect(output).toContain("line 19");
143
+ expect(output).toContain("lines hidden");
144
+ });
145
+
146
+ it("should display truncation indicator in the middle", () => {
147
+ const longOutput = Array(20)
148
+ .fill("line")
149
+ .map((_, i) => `line ${i}`)
150
+ .join("\n");
151
+ const instance = render(
152
+ <BashRunner command="test" output={longOutput} truncate={true} maxLines={10} />,
153
+ );
154
+
155
+ logInk(instance);
156
+
157
+ const output = instance.frames.join("");
158
+ // The truncation indicator should appear between first and last half
159
+ const lines = output.split("\n");
160
+ const truncationIndex = lines.findIndex((line) => line.includes("lines hidden"));
161
+ expect(truncationIndex).toBeGreaterThan(-1);
162
+ // Should be after first half and before last half
163
+ expect(truncationIndex).toBeGreaterThan(0);
164
+ expect(truncationIndex).toBeLessThan(lines.length - 1);
165
+ });
166
+
167
+ it("should call abbreviateHome with process.cwd()", () => {
168
+ const instance = render(<BashRunner command="pwd" output="output" />);
169
+
170
+ logInk(instance);
171
+
172
+ expect(pathModule.abbreviateHome).toHaveBeenCalledWith(process.cwd());
173
+ });
174
+
175
+ it("should handle command with special characters", () => {
176
+ const instance = render(<BashRunner command="echo 'hello world' && ls -la" output="output" />);
177
+
178
+ logInk(instance);
179
+
180
+ const output = instance.frames.join("");
181
+ expect(output).toContain("echo 'hello world' && ls -la");
182
+ });
183
+
184
+ it("should handle output with special characters", () => {
185
+ const specialOutput = "line with \"quotes\" and 'apostrophes' and $variables";
186
+ const instance = render(<BashRunner command="test" output={specialOutput} />);
187
+
188
+ logInk(instance);
189
+
190
+ const output = instance.frames.join("");
191
+ expect(output).toContain("quotes");
192
+ expect(output).toContain("apostrophes");
193
+ expect(output).toContain("$variables");
194
+ });
195
+
196
+ it("should handle very long single line output", () => {
197
+ const veryLongLine = "x".repeat(1000);
198
+ const instance = render(<BashRunner command="test" output={veryLongLine} />);
199
+
200
+ logInk(instance);
201
+
202
+ const output = instance.frames.join("");
203
+ expect(output).toContain("x");
204
+ expect(output).toBeDefined();
205
+ });
206
+
207
+ it("should handle edge case with maxLines=1", () => {
208
+ const longOutput = Array(10)
209
+ .fill("line")
210
+ .map((_, i) => `line ${i}`)
211
+ .join("\n");
212
+ const instance = render(
213
+ <BashRunner command="test" output={longOutput} truncate={true} maxLines={1} />,
214
+ );
215
+
216
+ logInk(instance);
217
+
218
+ const output = instance.frames.join("");
219
+ // Should show at least one line (the last line) even with maxLines=1
220
+ expect(output).toBeDefined();
221
+ expect(output).toContain("line 9"); // Last line should be shown
222
+ expect(output).toContain("lines hidden");
223
+ });
224
+
225
+ it("should handle large number of lines", () => {
226
+ const largeOutput = Array(1000)
227
+ .fill("line")
228
+ .map((_, i) => `line ${i}`)
229
+ .join("\n");
230
+ const instance = render(
231
+ <BashRunner command="test" output={largeOutput} truncate={true} maxLines={20} />,
232
+ );
233
+
234
+ logInk(instance);
235
+
236
+ const output = instance.frames.join("");
237
+ expect(output).toContain("lines hidden");
238
+ expect(output).toBeDefined();
239
+ // Should show first and last portions of output
240
+ expect(output).toContain("line 0");
241
+ expect(output).toContain("line 999");
242
+ });
243
+
244
+ it("should not show truncation when lines exactly equal maxLines", () => {
245
+ const exactOutput = Array(10)
246
+ .fill("line")
247
+ .map((_, i) => `line ${i}`)
248
+ .join("\n");
249
+ const instance = render(
250
+ <BashRunner command="test" output={exactOutput} truncate={true} maxLines={10} />,
251
+ );
252
+
253
+ logInk(instance);
254
+
255
+ const output = instance.frames.join("");
256
+ // Should not show truncation indicator when lines.length === maxLines
257
+ expect(output).toBeDefined();
258
+ expect(output).not.toContain("lines hidden");
259
+ // Should show all lines
260
+ expect(output).toContain("line 0");
261
+ expect(output).toContain("line 9");
262
+ });
263
+ });