@selesai/code 0.10.1 → 0.11.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 (128) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/config.d.ts +5 -3
  3. package/dist/config.js +6 -4
  4. package/dist/core/skills.d.ts +2 -0
  5. package/dist/core/skills.js +1 -0
  6. package/dist/defaults/settings.json +0 -3
  7. package/dist/extensions/auto-session-name.test.ts +157 -0
  8. package/dist/extensions/auto-session-name.ts +96 -0
  9. package/dist/extensions/cost-reconcile.test.ts +76 -0
  10. package/dist/extensions/cost-reconcile.ts +278 -0
  11. package/dist/extensions/enable-readonly-tools.test.ts +11 -0
  12. package/dist/extensions/enable-readonly-tools.ts +7 -3
  13. package/dist/extensions/package.json +3 -0
  14. package/dist/extensions/pi-tool-display/CHANGELOG.md +329 -0
  15. package/dist/extensions/pi-tool-display/LICENSE +21 -0
  16. package/dist/extensions/pi-tool-display/README.md +417 -0
  17. package/dist/extensions/pi-tool-display/config/config.example.json +40 -0
  18. package/dist/extensions/pi-tool-display/index.ts +3 -0
  19. package/dist/extensions/pi-tool-display/package-lock.json +2555 -0
  20. package/dist/extensions/pi-tool-display/package.json +86 -0
  21. package/dist/extensions/pi-tool-display/src/agent-dir.ts +35 -0
  22. package/dist/extensions/pi-tool-display/src/ansi-utils.ts +116 -0
  23. package/dist/extensions/pi-tool-display/src/bash-display.ts +213 -0
  24. package/dist/extensions/pi-tool-display/src/capabilities.ts +88 -0
  25. package/dist/extensions/pi-tool-display/src/config-modal.ts +541 -0
  26. package/dist/extensions/pi-tool-display/src/config-store.ts +304 -0
  27. package/dist/extensions/pi-tool-display/src/debug-logger.ts +150 -0
  28. package/dist/extensions/pi-tool-display/src/diff-presentation.ts +72 -0
  29. package/dist/extensions/pi-tool-display/src/diff-renderer.ts +2517 -0
  30. package/dist/extensions/pi-tool-display/src/disposable.ts +34 -0
  31. package/dist/extensions/pi-tool-display/src/extension-lifecycle.ts +14 -0
  32. package/dist/extensions/pi-tool-display/src/index.ts +109 -0
  33. package/dist/extensions/pi-tool-display/src/line-width-safety.ts +93 -0
  34. package/dist/extensions/pi-tool-display/src/modal-icons.ts +57 -0
  35. package/dist/extensions/pi-tool-display/src/pending-diff-preview.ts +370 -0
  36. package/dist/extensions/pi-tool-display/src/presets.ts +117 -0
  37. package/dist/extensions/pi-tool-display/src/render-utils.ts +186 -0
  38. package/dist/extensions/pi-tool-display/src/settings-inspector-modal.ts +475 -0
  39. package/dist/extensions/pi-tool-display/src/thinking-label.ts +343 -0
  40. package/dist/extensions/pi-tool-display/src/tool-call-box.ts +181 -0
  41. package/dist/extensions/pi-tool-display/src/tool-metadata.ts +139 -0
  42. package/dist/extensions/pi-tool-display/src/tool-overrides.ts +2168 -0
  43. package/dist/extensions/pi-tool-display/src/types.ts +109 -0
  44. package/dist/extensions/pi-tool-display/src/user-message-box-markdown.ts +64 -0
  45. package/dist/extensions/pi-tool-display/src/user-message-box-native.ts +66 -0
  46. package/dist/extensions/pi-tool-display/src/user-message-box-patch.ts +72 -0
  47. package/dist/extensions/pi-tool-display/src/user-message-box-renderer.ts +419 -0
  48. package/dist/extensions/pi-tool-display/src/user-message-box-utils.ts +106 -0
  49. package/dist/extensions/pi-tool-display/src/write-display-utils.ts +31 -0
  50. package/dist/extensions/pi-tool-display/src/zellij-modal.ts +1027 -0
  51. package/dist/extensions/pi-tool-display/tests/active-backlog-red.test.ts +65 -0
  52. package/dist/extensions/pi-tool-display/tests/ansi-utils.test.ts +345 -0
  53. package/dist/extensions/pi-tool-display/tests/bash-display.test.ts +442 -0
  54. package/dist/extensions/pi-tool-display/tests/capabilities-edge.test.ts +360 -0
  55. package/dist/extensions/pi-tool-display/tests/capabilities.test.ts +54 -0
  56. package/dist/extensions/pi-tool-display/tests/config-modal.test.ts +360 -0
  57. package/dist/extensions/pi-tool-display/tests/config-store.test.ts +87 -0
  58. package/dist/extensions/pi-tool-display/tests/custom-tool-overrides.test.ts +415 -0
  59. package/dist/extensions/pi-tool-display/tests/debug-logger-edge.test.ts +630 -0
  60. package/dist/extensions/pi-tool-display/tests/debug-logger.test.ts +62 -0
  61. package/dist/extensions/pi-tool-display/tests/diff-renderer-ansi.test.ts +55 -0
  62. package/dist/extensions/pi-tool-display/tests/diff-renderer-edge.test.ts +886 -0
  63. package/dist/extensions/pi-tool-display/tests/diff-renderer-numbering.test.ts +135 -0
  64. package/dist/extensions/pi-tool-display/tests/diff-renderer-width.test.ts +454 -0
  65. package/dist/extensions/pi-tool-display/tests/diff-renderer-wrap.test.ts +169 -0
  66. package/dist/extensions/pi-tool-display/tests/index-integration.test.ts +354 -0
  67. package/dist/extensions/pi-tool-display/tests/pending-diff-preview.test.ts +79 -0
  68. package/dist/extensions/pi-tool-display/tests/presets-edge.test.ts +229 -0
  69. package/dist/extensions/pi-tool-display/tests/presets.test.ts +26 -0
  70. package/dist/extensions/pi-tool-display/tests/reload-behavior.test.ts +1241 -0
  71. package/dist/extensions/pi-tool-display/tests/render-utils.test.ts +710 -0
  72. package/dist/extensions/pi-tool-display/tests/settings-inspector.test.ts +450 -0
  73. package/dist/extensions/pi-tool-display/tests/thinking-label-edge.test.ts +904 -0
  74. package/dist/extensions/pi-tool-display/tests/thinking-label.test.ts +72 -0
  75. package/dist/extensions/pi-tool-display/tests/tool-call-box.test.ts +105 -0
  76. package/dist/extensions/pi-tool-display/tests/tool-overrides-config.test.ts +613 -0
  77. package/dist/extensions/pi-tool-display/tests/tool-overrides-mcp.test.ts +499 -0
  78. package/dist/extensions/pi-tool-display/tests/tool-overrides-registration.test.ts +315 -0
  79. package/dist/extensions/pi-tool-display/tests/tool-overrides-write-meta.test.ts +68 -0
  80. package/dist/extensions/pi-tool-display/tests/tool-ui-utils.test.ts +413 -0
  81. package/dist/extensions/pi-tool-display/tests/user-message-box-performance-red.test.ts +176 -0
  82. package/dist/extensions/pi-tool-display/tests/user-message-box.test.ts +908 -0
  83. package/dist/extensions/pi-tool-display/tests/zellij-modal.test.ts +848 -0
  84. package/dist/extensions/pi-tool-display/tool-display-api-consumer.d.ts +30 -0
  85. package/dist/extensions/pi-tool-display/tool-display-api-consumer.js +40 -0
  86. package/dist/extensions/pi-zentui/extensions/zentui/format.ts +46 -1
  87. package/dist/extensions/pi-zentui/extensions/zentui/index.ts +10 -0
  88. package/dist/extensions/pi-zentui/extensions/zentui/user-message-styles.ts +1 -1
  89. package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +3 -3
  90. package/dist/extensions/pi-zentui/test/format.test.ts +50 -2
  91. package/dist/modes/interactive/components/startup-box.d.ts +38 -0
  92. package/dist/modes/interactive/components/startup-box.js +90 -0
  93. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  94. package/dist/modes/interactive/interactive-mode.js +85 -122
  95. package/dist/skills/agent-browser/SKILL.md +1 -0
  96. package/dist/skills/batch-grill-me/SKILL.md +1 -0
  97. package/dist/skills/brandkit/SKILL.md +1 -0
  98. package/dist/skills/design-references/SKILL.md +1 -0
  99. package/dist/skills/design-taste-frontend/SKILL.md +1 -0
  100. package/dist/skills/design-taste-frontend-v1/SKILL.md +1 -0
  101. package/dist/skills/full-output-enforcement/SKILL.md +1 -0
  102. package/dist/skills/gpt-taste/SKILL.md +1 -0
  103. package/dist/skills/grill-me/SKILL.md +1 -0
  104. package/dist/skills/handoff/SKILL.md +1 -0
  105. package/dist/skills/handoff-text/SKILL.md +1 -0
  106. package/dist/skills/high-end-visual-design/SKILL.md +1 -0
  107. package/dist/skills/image-to-code/SKILL.md +1 -0
  108. package/dist/skills/imagegen-frontend-mobile/SKILL.md +1 -0
  109. package/dist/skills/imagegen-frontend-web/SKILL.md +1 -0
  110. package/dist/skills/implanger/SKILL.md +1 -0
  111. package/dist/skills/improve-codebase/SKILL.md +1 -0
  112. package/dist/skills/industrial-brutalist-ui/SKILL.md +1 -0
  113. package/dist/skills/minimalist-ui/SKILL.md +1 -0
  114. package/dist/skills/pi-subagents/SKILL.md +1 -0
  115. package/dist/skills/planger/SKILL.md +1 -0
  116. package/dist/skills/ponytail/SKILL.md +1 -0
  117. package/dist/skills/ponytail-audit/SKILL.md +1 -0
  118. package/dist/skills/ponytail-debt/SKILL.md +1 -0
  119. package/dist/skills/ponytail-gain/SKILL.md +1 -0
  120. package/dist/skills/ponytail-help/SKILL.md +1 -0
  121. package/dist/skills/ponytail-review/SKILL.md +1 -0
  122. package/dist/skills/redesign-existing-projects/SKILL.md +1 -0
  123. package/dist/skills/selesai-handoff/SKILL.md +1 -0
  124. package/dist/skills/stitch-design-taste/SKILL.md +1 -0
  125. package/dist/skills/unlazy/SKILL.md +1 -0
  126. package/dist/skills/web-design-guidelines/SKILL.md +1 -0
  127. package/dist/skills/workflow/SKILL.md +1 -0
  128. package/package.json +3 -3
@@ -0,0 +1,442 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { renderBashCall } from "../src/bash-display.ts";
5
+
6
+ // ─── Test Helpers ────────────────────────────────────────────────────────────
7
+
8
+ const BASH_SPINNER_STATE_KEY = "__piToolDisplayBashSpinner";
9
+
10
+ interface BashCallRenderTheme {
11
+ fg(color: string, text: string): string;
12
+ bold(text: string): string;
13
+ }
14
+
15
+ interface BashCallRenderContextLike {
16
+ executionStarted: boolean;
17
+ isPartial: boolean;
18
+ invalidate(): void;
19
+ lastComponent?: unknown;
20
+ state?: unknown;
21
+ }
22
+
23
+ /** Pass-through theme that returns text unchanged */
24
+ function createPassThroughTheme(): BashCallRenderTheme {
25
+ return {
26
+ fg: (_color: string, text: string): string => text,
27
+ bold: (text: string): string => text,
28
+ };
29
+ }
30
+
31
+ /** ANSI-producing theme so rendered output can be verified structurally */
32
+ function createAnsiTheme(): BashCallRenderTheme {
33
+ return {
34
+ fg: (color: string, text: string): string =>
35
+ `\x1b[${color === "warning" ? "93" : color === "muted" ? "90" : color === "toolTitle" ? "94" : color === "accent" ? "92" : "0"}m${text}\x1b[0m`,
36
+ bold: (text: string): string => `\x1b[1m${text}\x1b[0m`,
37
+ };
38
+ }
39
+
40
+ function makeContext(overrides: Partial<BashCallRenderContextLike> = {}): BashCallRenderContextLike {
41
+ return {
42
+ executionStarted: false,
43
+ isPartial: false,
44
+ invalidate: () => {},
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function renderedText(component: Text): string {
50
+ return component.render(120).map((line) => line.trimEnd()).join("\n").trim();
51
+ }
52
+
53
+ /**
54
+ * Create a spinner context and render. Returns the component plus a cleanup
55
+ * function that transitions to shouldSpin=false (stopping the interval timer).
56
+ */
57
+ function createSpinningBashCall(
58
+ args: { command?: string; timeout?: number },
59
+ state: Record<string, unknown>,
60
+ extra?: Partial<BashCallRenderContextLike>,
61
+ ): { text: Text; stop(): void } {
62
+ const text = renderBashCall(
63
+ args,
64
+ createPassThroughTheme(),
65
+ makeContext({
66
+ executionStarted: true,
67
+ isPartial: true,
68
+ state,
69
+ ...extra,
70
+ }),
71
+ );
72
+ return {
73
+ text,
74
+ stop: () => {
75
+ renderBashCall(
76
+ args,
77
+ createPassThroughTheme(),
78
+ makeContext({
79
+ executionStarted: true,
80
+ isPartial: false,
81
+ state,
82
+ lastComponent: text,
83
+ ...extra,
84
+ }),
85
+ );
86
+ },
87
+ };
88
+ }
89
+
90
+ // ─── Args Shapes ─────────────────────────────────────────────────────────────
91
+
92
+ test("renderBashCall uses ellipsis when command is missing", () => {
93
+ const text = renderBashCall({}, createPassThroughTheme(), makeContext());
94
+ assert.equal(renderedText(text), "$ ...");
95
+ });
96
+
97
+ test("renderBashCall uses ellipsis when command is empty string", () => {
98
+ const text = renderBashCall({ command: "" }, createPassThroughTheme(), makeContext());
99
+ assert.equal(renderedText(text), "$ ...");
100
+ });
101
+
102
+ test("renderBashCall uses ellipsis when command is only whitespace", () => {
103
+ const text = renderBashCall({ command: " " }, createPassThroughTheme(), makeContext());
104
+ assert.equal(renderedText(text), "$ ...");
105
+ });
106
+
107
+ test("renderBashCall displays short command", () => {
108
+ const text = renderBashCall({ command: "npm test" }, createPassThroughTheme(), makeContext());
109
+ assert.equal(renderedText(text), "$ npm test");
110
+ });
111
+
112
+ test("renderBashCall handles very long commands without crashing", () => {
113
+ // Text wraps at render width (120), extremely long commands wrap to multiple lines
114
+ const longCmd = "node " + "a".repeat(200);
115
+ const text = renderBashCall({ command: longCmd }, createPassThroughTheme(), makeContext());
116
+ const output = renderedText(text);
117
+ // Command prefix should still appear (the "$ " prefix is always present)
118
+ assert.ok(output.includes("$"), "dollar sign should appear");
119
+ assert.ok(output.includes("node"), "command text should appear");
120
+ // The rendered output should contain the command text
121
+ assert.ok(output.length > 10, "output should have content");
122
+ });
123
+
124
+ test("renderBashCall displays multiline command", () => {
125
+ const text = renderBashCall(
126
+ { command: "echo hello\necho world" },
127
+ createPassThroughTheme(),
128
+ makeContext(),
129
+ );
130
+ assert.ok(renderedText(text).includes("echo hello\necho world"));
131
+ });
132
+
133
+ test("renderBashCall appends timeout suffix when timeout is provided", () => {
134
+ const text = renderBashCall(
135
+ { command: "npm test", timeout: 30 },
136
+ createPassThroughTheme(),
137
+ makeContext(),
138
+ );
139
+ assert.equal(renderedText(text), "$ npm test (timeout 30s)");
140
+ });
141
+
142
+ test("renderBashCall does not include timeout suffix when timeout is zero", () => {
143
+ const text = renderBashCall(
144
+ { command: "npm test", timeout: 0 },
145
+ createPassThroughTheme(),
146
+ makeContext(),
147
+ );
148
+ assert.equal(renderedText(text), "$ npm test");
149
+ });
150
+
151
+ test("renderBashCall does not include timeout suffix when timeout is undefined", () => {
152
+ const text = renderBashCall(
153
+ { command: "npm test" },
154
+ createPassThroughTheme(),
155
+ makeContext(),
156
+ );
157
+ assert.equal(renderedText(text), "$ npm test");
158
+ });
159
+
160
+ // ─── Theme Variations ────────────────────────────────────────────────────────
161
+
162
+ test("renderBashCall applies ANSI bold to the dollar sign with ANSI theme", () => {
163
+ const text = renderBashCall({ command: "ls" }, createAnsiTheme(), makeContext());
164
+ const rendered = renderedText(text);
165
+ assert.ok(rendered.includes("\x1b[1m$\x1b[0m"), `expected bold $ in: ${JSON.stringify(rendered)}`);
166
+ });
167
+
168
+ test("renderBashCall applies ANSI color to command with ANSI theme", () => {
169
+ const text = renderBashCall({ command: "ls" }, createAnsiTheme(), makeContext());
170
+ const rendered = renderedText(text);
171
+ assert.ok(rendered.includes("\x1b[92mls\x1b[0m"), `expected green ls in: ${JSON.stringify(rendered)}`);
172
+ });
173
+
174
+ // ─── Context States ──────────────────────────────────────────────────────────
175
+
176
+ test("renderBashCall no spinner when executionStarted is false", () => {
177
+ const text = renderBashCall(
178
+ { command: "npm test" },
179
+ createPassThroughTheme(),
180
+ makeContext({ executionStarted: false, isPartial: false }),
181
+ );
182
+ assert.equal(renderedText(text), "$ npm test");
183
+ });
184
+
185
+ test("renderBashCall no spinner when isPartial is false even if executionStarted", () => {
186
+ const text = renderBashCall(
187
+ { command: "npm test" },
188
+ createPassThroughTheme(),
189
+ makeContext({ executionStarted: true, isPartial: false }),
190
+ );
191
+ assert.equal(renderedText(text), "$ npm test");
192
+ });
193
+
194
+ test("renderBashCall shows spinner when executionStarted and isPartial are true", () => {
195
+ const state: Record<string, unknown> = {};
196
+ const { text, stop } = createSpinningBashCall({ command: "npm test" }, state);
197
+ try {
198
+ const rendered = renderedText(text);
199
+ assert.match(rendered, /^⠋ \$ npm test · 0s$/);
200
+ } finally {
201
+ stop();
202
+ }
203
+ });
204
+
205
+ test("renderBashCall spinner animates frame index over time via setInterval", async () => {
206
+ const state: Record<string, unknown> = {};
207
+ let invalidateCount = 0;
208
+ const { text, stop } = createSpinningBashCall(
209
+ { command: "npm test" },
210
+ state,
211
+ { invalidate: () => { invalidateCount++; } },
212
+ );
213
+ try {
214
+ const frame0 = renderedText(text);
215
+ assert.match(frame0, /^⠋/);
216
+
217
+ // Wait for at least one interval tick (750ms interval)
218
+ await new Promise((r) => setTimeout(r, 800));
219
+
220
+ const frame1 = renderedText(text);
221
+ assert.notEqual(frame1, frame0, "spinner frame should advance");
222
+ assert.match(frame1, /^⠙/);
223
+ assert.ok(invalidateCount > 0, "invalidate should be called during animation");
224
+ } finally {
225
+ stop();
226
+ }
227
+ });
228
+
229
+ test("renderBashCall stops spinner and clears timer when shouldSpin becomes false", async () => {
230
+ const state: Record<string, unknown> = {};
231
+ const { text, stop } = createSpinningBashCall({ command: "npm test" }, state);
232
+
233
+ assert.match(renderedText(text), /^⠋/);
234
+
235
+ // Transition to complete state via stop
236
+ stop();
237
+ assert.equal(renderedText(text), "$ npm test");
238
+
239
+ // Wait to verify no further animation
240
+ await new Promise((r) => setTimeout(r, 100));
241
+ assert.equal(renderedText(text), "$ npm test", "no spinner after completion");
242
+ });
243
+
244
+ test("renderBashCall reuses existing spinner state from context.state", () => {
245
+ const state: Record<string, unknown> = {};
246
+ const { text: text1, stop } = createSpinningBashCall({ command: "npm test" }, state);
247
+ assert.ok(state[BASH_SPINNER_STATE_KEY] !== undefined, "state should have spinner state");
248
+
249
+ // Second call with same state
250
+ const text2 = renderBashCall(
251
+ { command: "npm test" },
252
+ createPassThroughTheme(),
253
+ makeContext({
254
+ executionStarted: true,
255
+ isPartial: true,
256
+ state,
257
+ lastComponent: text1,
258
+ }),
259
+ );
260
+ const state2 = state[BASH_SPINNER_STATE_KEY] as Record<string, unknown>;
261
+ assert.ok(state2 !== undefined);
262
+ assert.ok(state2.frameIndex !== undefined);
263
+ stop();
264
+ });
265
+
266
+ test("renderBashCall stores state under __piToolDisplayBashSpinner key", () => {
267
+ const state: Record<string, unknown> = {};
268
+ const { stop } = createSpinningBashCall({ command: "npm test" }, state);
269
+ try {
270
+ assert.ok(state[BASH_SPINNER_STATE_KEY] !== undefined);
271
+ const spinnerState = state[BASH_SPINNER_STATE_KEY] as Record<string, unknown>;
272
+ assert.equal(typeof spinnerState.frameIndex, "number");
273
+ assert.equal(typeof spinnerState.startedAt, "number");
274
+ } finally {
275
+ stop();
276
+ }
277
+ });
278
+
279
+ test("renderBashCall does not create spinner state when state is null/undefined", () => {
280
+ const text = renderBashCall(
281
+ { command: "npm test" },
282
+ createPassThroughTheme(),
283
+ makeContext({
284
+ executionStarted: true,
285
+ isPartial: true,
286
+ state: undefined,
287
+ }),
288
+ );
289
+ assert.equal(renderedText(text), "$ npm test");
290
+ });
291
+
292
+ test("renderBashCall does not create spinner state when state is a primitive", () => {
293
+ const text = renderBashCall(
294
+ { command: "npm test" },
295
+ createPassThroughTheme(),
296
+ makeContext({
297
+ executionStarted: true,
298
+ isPartial: true,
299
+ state: "string-state",
300
+ }),
301
+ );
302
+ assert.equal(renderedText(text), "$ npm test");
303
+ });
304
+
305
+ test("renderBashCall creates spinner state when state is an array (arrays are objects)", () => {
306
+ const stateArray: unknown[] = [];
307
+ const text = renderBashCall(
308
+ { command: "npm test" },
309
+ createPassThroughTheme(),
310
+ makeContext({
311
+ executionStarted: true,
312
+ isPartial: true,
313
+ state: stateArray,
314
+ }),
315
+ );
316
+ // Arrays are objects in JS, so the implementation treats them as valid state carriers
317
+ assert.match(renderedText(text), /^⠋/);
318
+ // Clean up the interval set on the array
319
+ const spyState = (stateArray as unknown as Record<string, unknown>)[BASH_SPINNER_STATE_KEY] as Record<string, unknown> | undefined;
320
+ if (spyState?.timer) {
321
+ clearInterval(spyState.timer as ReturnType<typeof setInterval>);
322
+ }
323
+ });
324
+
325
+ // ─── Elapsed Time Formatting ─────────────────────────────────────────────────
326
+
327
+ test("renderBashCall shows elapsed seconds when spinning and under 60s", () => {
328
+ const state: Record<string, unknown> = {};
329
+ state[BASH_SPINNER_STATE_KEY] = { frameIndex: 0, startedAt: Date.now() };
330
+ const { text, stop } = createSpinningBashCall({ command: "npm test" }, state);
331
+ try {
332
+ assert.match(renderedText(text), /· \d+s$/);
333
+ } finally {
334
+ stop();
335
+ }
336
+ });
337
+
338
+ // ─── Multiple Concurrent Bash Calls ──────────────────────────────────────────
339
+
340
+ test("multiple concurrent bash calls have independent spinner states", () => {
341
+ const stateA: Record<string, unknown> = {};
342
+ const stateB: Record<string, unknown> = {};
343
+
344
+ const { text: textA, stop: stopA } = createSpinningBashCall({ command: "npm test" }, stateA);
345
+ const { text: textB, stop: stopB } = createSpinningBashCall({ command: "npm build" }, stateB);
346
+ try {
347
+ const aKey = stateA[BASH_SPINNER_STATE_KEY] as Record<string, unknown>;
348
+ const bKey = stateB[BASH_SPINNER_STATE_KEY] as Record<string, unknown>;
349
+ assert.ok(aKey !== bKey, "each bash call gets its own spinner state object");
350
+
351
+ // Each renders independently with its own command
352
+ assert.match(renderedText(textA), /npm test/);
353
+ assert.match(renderedText(textB), /npm build/);
354
+
355
+ // Complete stateA independently (spinner stops for A)
356
+ stopA();
357
+ assert.equal(renderedText(textA), "$ npm test", "A spinner stopped after stopA");
358
+
359
+ // stateB should still show spinner prefix (frame may not have advanced yet)
360
+ assert.match(renderedText(textB), /^⠋/, "B should still show spinner after A completed");
361
+ } finally {
362
+ stopA();
363
+ stopB();
364
+ }
365
+ });
366
+
367
+ // ─── Rapid Invalidate / Repeated Calls ───────────────────────────────────────
368
+
369
+ test("renderBashCall handles repeated calls without creating multiple timers", () => {
370
+ const state: Record<string, unknown> = {};
371
+ let invalidateCount = 0;
372
+
373
+ const { text, stop } = createSpinningBashCall(
374
+ { command: "npm test" },
375
+ state,
376
+ { invalidate: () => { invalidateCount++; } },
377
+ );
378
+
379
+ // Call renderBashCall repeatedly with same state (simulating rapid re-renders)
380
+ for (let i = 0; i < 5; i++) {
381
+ renderBashCall(
382
+ { command: "npm test" },
383
+ createPassThroughTheme(),
384
+ makeContext({
385
+ executionStarted: true,
386
+ isPartial: true,
387
+ state,
388
+ lastComponent: text,
389
+ invalidate: () => { invalidateCount++; },
390
+ }),
391
+ );
392
+ }
393
+
394
+ const spinnerState = state[BASH_SPINNER_STATE_KEY] as Record<string, unknown>;
395
+ // Only one timer should be active (timer is only set if !spinnerState.timer)
396
+ const timerCount = spinnerState.timer !== undefined ? 1 : 0;
397
+ assert.equal(timerCount, 1, "multiple renderBashCall calls should not create multiple timers");
398
+
399
+ stop();
400
+ });
401
+
402
+ // ─── lastComponent Preservation ──────────────────────────────────────────────
403
+
404
+ test("renderBashCall preserves the same Text component reference when lastComponent is a Text", () => {
405
+ const initialState: Record<string, unknown> = {};
406
+ const { text: first, stop } = createSpinningBashCall({ command: "npm test" }, initialState);
407
+
408
+ const second = renderBashCall(
409
+ { command: "npm test" },
410
+ createPassThroughTheme(),
411
+ makeContext({
412
+ executionStarted: true,
413
+ isPartial: false,
414
+ state: initialState,
415
+ lastComponent: first,
416
+ }),
417
+ );
418
+
419
+ assert.equal(first, second, "should return the same Text instance via lastComponent");
420
+ stop();
421
+ });
422
+
423
+ // ─── Edge Cases ──────────────────────────────────────────────────────────────
424
+
425
+ test("renderBashCall handles command with special characters like $ and backticks", () => {
426
+ const text = renderBashCall(
427
+ { command: "echo $HOME && echo `pwd`" },
428
+ createPassThroughTheme(),
429
+ makeContext(),
430
+ );
431
+ assert.ok(renderedText(text).includes("$HOME"));
432
+ assert.ok(renderedText(text).includes("`pwd`"));
433
+ });
434
+
435
+ test("renderBashCall handles numeric timeout with decimal value", () => {
436
+ const text = renderBashCall(
437
+ { command: "sleep 1", timeout: 2.5 },
438
+ createPassThroughTheme(),
439
+ makeContext(),
440
+ );
441
+ assert.equal(renderedText(text), "$ sleep 1 (timeout 2.5s)");
442
+ });