@markjaquith/agency 3.2.13 → 3.2.15

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 (69) hide show
  1. package/package.json +7 -2
  2. package/src/workbase/schemas.ts +12 -0
  3. package/fixtures/protocol/orchestration-recipes.json +0 -53
  4. package/pi-extensions/agency.test.ts +0 -117
  5. package/src/check-commits.test.ts +0 -58
  6. package/src/cli-parser.test.ts +0 -945
  7. package/src/cli.test.ts +0 -1889
  8. package/src/commands/act.test.ts +0 -418
  9. package/src/commands/archive.test.ts +0 -236
  10. package/src/commands/context.test.ts +0 -665
  11. package/src/commands/description.test.ts +0 -103
  12. package/src/commands/doctor.test.ts +0 -185
  13. package/src/commands/epic.test.ts +0 -196
  14. package/src/commands/init.test.ts +0 -117
  15. package/src/commands/integration.test.ts +0 -165
  16. package/src/commands/pr.test.ts +0 -248
  17. package/src/commands/push.test.ts +0 -56
  18. package/src/commands/read-only.test.ts +0 -161
  19. package/src/commands/repo.test.ts +0 -199
  20. package/src/commands/restore.test.ts +0 -106
  21. package/src/commands/status.test.ts +0 -113
  22. package/src/commands/sync.test.ts +0 -200
  23. package/src/commands/task-phase.test.ts +0 -410
  24. package/src/commands/task.test.ts +0 -281
  25. package/src/commands/validate.test.ts +0 -186
  26. package/src/commands/work.test.ts +0 -1375
  27. package/src/commands/workbase.test.ts +0 -170
  28. package/src/graph-schema.test.ts +0 -124
  29. package/src/protocol.test.ts +0 -163
  30. package/src/readiness.test.ts +0 -105
  31. package/src/services/ArchiveBulkService.test.ts +0 -384
  32. package/src/services/ArchiveService.test.ts +0 -1092
  33. package/src/services/EpicService.test.ts +0 -74
  34. package/src/services/FileSystemService.test.ts +0 -81
  35. package/src/services/GraphMutationService.test.ts +0 -486
  36. package/src/services/GraphService.test.ts +0 -494
  37. package/src/services/IntegrationService.test.ts +0 -1091
  38. package/src/services/LifecycleTransaction.test.ts +0 -145
  39. package/src/services/PullRequestService.test.ts +0 -716
  40. package/src/services/PushService.test.ts +0 -408
  41. package/src/services/ReadinessService.test.ts +0 -290
  42. package/src/services/RepositoryService.test.ts +0 -789
  43. package/src/services/ReviewService.test.ts +0 -408
  44. package/src/services/SyncService.test.ts +0 -1377
  45. package/src/services/TaskPhaseService.test.ts +0 -852
  46. package/src/services/VersionControlService.test.ts +0 -62
  47. package/src/services/WorkbaseService.test.ts +0 -910
  48. package/src/services/WorktreeLock.test.ts +0 -205
  49. package/src/services/WorktreePerformance.test.ts +0 -71
  50. package/src/services/WorktreeService.test.ts +0 -2292
  51. package/src/test-utils.ts +0 -110
  52. package/src/usage-log.test.ts +0 -188
  53. package/src/utils/chooser.test.ts +0 -192
  54. package/src/utils/effect.test.ts +0 -30
  55. package/src/utils/interactive.pty.test.ts +0 -128
  56. package/src/utils/interactive.test.tsx +0 -860
  57. package/src/utils/process.test.ts +0 -132
  58. package/src/utils/progress.test.ts +0 -37
  59. package/src/work-view.test.ts +0 -151
  60. package/src/workbase/agent-command.test.ts +0 -128
  61. package/src/workbase/checkout-command.test.ts +0 -62
  62. package/src/workbase/delivery-command.test.ts +0 -180
  63. package/src/workbase/dependency-graph.test.ts +0 -50
  64. package/src/workbase/execution-contract.test.ts +0 -161
  65. package/src/workbase/frontmatter.test.ts +0 -67
  66. package/src/workbase/repository-reference.test.ts +0 -25
  67. package/src/workbase/schemas.test.ts +0 -543
  68. package/src/workbase/work-target.test.ts +0 -108
  69. package/src/workbase/worktree-command.test.ts +0 -61
@@ -1,860 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { KeyCodes, type MockInput } from "@opentui/core/testing"
3
- import { testRender } from "@opentui/solid"
4
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
5
- import { tmpdir } from "node:os"
6
- import { dirname, join } from "node:path"
7
- import {
8
- fuzzyChoices,
9
- hierarchyPrefix,
10
- InteractiveSelectPrompt,
11
- InteractiveTextPrompt,
12
- interactiveRendererConfig,
13
- interactiveSelectRendererConfig,
14
- } from "./interactive"
15
-
16
- const submitEditedText = async (
17
- edit: (input: MockInput) => void | Promise<void>,
18
- ) => {
19
- let submitted: string | null | undefined
20
- const setup = await testRender(
21
- () => (
22
- <InteractiveTextPrompt
23
- prompt="Text"
24
- onDone={(value) => {
25
- submitted = value
26
- }}
27
- />
28
- ),
29
- { width: 60, height: 4 },
30
- )
31
- try {
32
- await setup.renderer.setupTerminal()
33
- await setup.renderOnce()
34
- await Bun.sleep(0)
35
- await edit(setup.mockInput)
36
- setup.mockInput.pressEnter()
37
- await setup.waitFor(() => submitted !== undefined)
38
- return submitted
39
- } finally {
40
- setup.renderer.destroy()
41
- }
42
- }
43
-
44
- describe("OpenTUI interaction", () => {
45
- test("selects the Solid JSX runtime without the project preload", async () => {
46
- const source = await Bun.file(
47
- new URL("./interactive.tsx", import.meta.url),
48
- ).text()
49
- const theme = await Bun.file(new URL("./theme.ts", import.meta.url)).text()
50
- const root = await mkdtemp(join(tmpdir(), "agency-interactive-jsx-"))
51
- const entrypoint = join(
52
- root,
53
- "node_modules",
54
- "@markjaquith",
55
- "agency",
56
- "interactive.tsx",
57
- )
58
- await mkdir(dirname(entrypoint), { recursive: true })
59
- await writeFile(entrypoint, source)
60
- await writeFile(join(dirname(entrypoint), "theme.ts"), theme)
61
-
62
- try {
63
- const result = await Bun.build({
64
- entrypoints: [entrypoint],
65
- packages: "external",
66
- target: "bun",
67
- })
68
- expect(result.success).toBeTrue()
69
- const output = await result.outputs[0]!.text()
70
- expect(output).toContain("@opentui/solid/jsx-dev-runtime")
71
- expect(output).not.toContain("react/jsx-dev-runtime")
72
- } finally {
73
- await rm(root, { recursive: true, force: true })
74
- }
75
- })
76
-
77
- test("uses the split-footer renderer contract", () => {
78
- expect(interactiveRendererConfig).toMatchObject({
79
- screenMode: "split-footer",
80
- footerHeight: 5,
81
- externalOutputMode: "capture-stdout",
82
- clearOnShutdown: false,
83
- })
84
- })
85
-
86
- test("uses the full alternate screen for selectors", () => {
87
- expect(interactiveSelectRendererConfig).toMatchObject({
88
- screenMode: "alternate-screen",
89
- externalOutputMode: "passthrough",
90
- clearOnShutdown: false,
91
- })
92
- })
93
-
94
- test("fills the available rows and keeps the selection visible after resize", async () => {
95
- const setup = await testRender(
96
- () => (
97
- <InteractiveSelectPrompt
98
- prompt="Work on"
99
- choices={Array.from({ length: 10 }, (_, index) => ({
100
- key: String(index),
101
- label: `choice-${index}`,
102
- }))}
103
- onDone={() => undefined}
104
- />
105
- ),
106
- { width: 40, height: 8 },
107
- )
108
- try {
109
- await setup.renderer.setupTerminal()
110
- await setup.renderOnce()
111
- await Bun.sleep(0)
112
-
113
- let frame = setup.captureCharFrame()
114
- for (let index = 0; index < 5; index++) {
115
- expect(frame).toContain(`choice-${index}`)
116
- }
117
- expect(frame).not.toContain("choice-5")
118
-
119
- for (let index = 0; index < 7; index++) {
120
- setup.mockInput.pressArrow("down")
121
- }
122
- await setup.flush()
123
- frame = setup.captureCharFrame()
124
- expect(frame).toContain("choice-5")
125
- expect(frame).toContain("▌ choice-7")
126
- expect(frame).toContain("choice-9")
127
-
128
- setup.resize(40, 5)
129
- await setup.flush()
130
- frame = setup.captureCharFrame()
131
- expect(frame).not.toContain("choice-5")
132
- expect(frame).toContain("choice-6")
133
- expect(frame).toContain("▌ choice-7")
134
- expect(frame).not.toContain("choice-8")
135
- expect(frame).not.toContain("choice-9")
136
- } finally {
137
- setup.renderer.destroy()
138
- }
139
- })
140
-
141
- test("ranks case-insensitive fuzzy matches", () => {
142
- const choices = [
143
- { key: "nested", label: "Manage Agency" },
144
- { key: "prefix", label: "Agency" },
145
- { key: "other", label: "Website" },
146
- ]
147
-
148
- expect(fuzzyChoices(choices, "AG").map((choice) => choice.key)).toEqual([
149
- "prefix",
150
- "nested",
151
- ])
152
- expect(fuzzyChoices(choices, "mgy").map((choice) => choice.key)).toEqual([
153
- "nested",
154
- ])
155
- expect(fuzzyChoices(choices, "zzz")).toEqual([])
156
- })
157
-
158
- test("builds continuous hierarchy connectors for roots, children, and phases", () => {
159
- const choices = [
160
- { key: "epic", label: "epic delivery", depth: 0 },
161
- { key: "multi", label: "task multi", depth: 1 },
162
- { key: "build", label: "phase build", depth: 2 },
163
- { key: "verify", label: "phase verify", depth: 2 },
164
- { key: "single", label: "task single", depth: 1 },
165
- { key: "empty", label: "epic empty", depth: 0 },
166
- { key: "standalone", label: "task standalone", depth: 0 },
167
- ]
168
-
169
- expect(choices.map((_, index) => hierarchyPrefix(choices, index))).toEqual([
170
- "╭─ ",
171
- "│ ╭─ ",
172
- "│ │ ╭─ ",
173
- "│ │ ╰─ ",
174
- "│ ╰─ ",
175
- "├─ ",
176
- "╰─ ",
177
- ])
178
- expect(hierarchyPrefix([{ key: "only", label: "Only", depth: 0 }], 0)).toBe(
179
- "╰─ ",
180
- )
181
- expect(hierarchyPrefix([{ key: "flat", label: "Flat" }], 0)).toBe("")
182
- })
183
-
184
- test("renders hierarchy only while the filter is empty", async () => {
185
- const setup = await testRender(
186
- () => (
187
- <InteractiveSelectPrompt
188
- prompt="Work on"
189
- choices={[
190
- { key: "epic", label: "epic delivery", depth: 0 },
191
- { key: "child", label: "task delivery-child", depth: 1 },
192
- { key: "standalone", label: "task standalone", depth: 0 },
193
- ]}
194
- onDone={() => undefined}
195
- />
196
- ),
197
- { width: 40, height: 5 },
198
- )
199
- try {
200
- await setup.renderer.setupTerminal()
201
- await setup.renderOnce()
202
- await Bun.sleep(0)
203
-
204
- let frame = setup.captureCharFrame()
205
- expect(frame).toContain("▌ ╭─ epic delivery")
206
- expect(frame).toContain(" │ ╰─ task delivery-child")
207
-
208
- setup.mockInput.pressArrow("down")
209
- await setup.flush()
210
- frame = setup.captureCharFrame()
211
- expect(frame).toContain(" ╭─ epic delivery")
212
- expect(frame).toContain("▌ │ ╰─ task delivery-child")
213
-
214
- await setup.mockInput.typeText("delivery")
215
- await setup.flush()
216
- frame = setup.captureCharFrame()
217
- expect(frame).toContain("▌ epic delivery")
218
- expect(frame).toContain(" task delivery-child")
219
- expect(frame).not.toMatch(/[╭│├╰─]/)
220
-
221
- setup.mockInput.pressKey("u", { ctrl: true })
222
- await setup.flush()
223
- setup.resize(32, 5)
224
- await setup.flush()
225
- frame = setup.captureCharFrame()
226
- expect(frame).toContain("▌ ╭─ epic delivery")
227
- expect(frame).toContain(" │ ╰─ task delivery-child")
228
- } finally {
229
- setup.renderer.destroy()
230
- }
231
- })
232
-
233
- test("keeps connectors stable while highlighting the full active row", async () => {
234
- const setup = await testRender(
235
- () => (
236
- <InteractiveSelectPrompt
237
- prompt="Work on"
238
- choices={[
239
- { key: "agency", label: "agency", depth: 0 },
240
- { key: "web", label: "web", depth: 0 },
241
- ]}
242
- onDone={() => undefined}
243
- />
244
- ),
245
- { width: 40, height: 5 },
246
- )
247
- try {
248
- await setup.renderer.setupTerminal()
249
- await setup.renderOnce()
250
- await Bun.sleep(0)
251
-
252
- let lines = setup.captureSpans().lines
253
- let active = lines.find((line) =>
254
- line.spans.some((span) => span.text === "▌ "),
255
- )!
256
- expect(
257
- active.spans.find((span) => span.text === "▌ ")?.fg.toInts(),
258
- ).toEqual([198, 160, 246, 255])
259
- expect(
260
- active.spans.find((span) => span.text === "╭─ ")?.fg.toInts(),
261
- ).toEqual([128, 135, 162, 255])
262
- expect(active.spans.map((span) => span.bg.toInts())).toEqual(
263
- Array.from({ length: active.spans.length }, () => [73, 77, 100, 255]),
264
- )
265
-
266
- setup.mockInput.pressArrow("down")
267
- await setup.flush()
268
- lines = setup.captureSpans().lines
269
- active = lines.find((line) =>
270
- line.spans.some((span) => span.text === "▌ "),
271
- )!
272
- expect(
273
- lines
274
- .flatMap((line) => line.spans)
275
- .find((span) => span.text === "╭─ ")
276
- ?.fg.toInts(),
277
- ).toEqual([128, 135, 162, 255])
278
- expect(
279
- active.spans.find((span) => span.text === "╰─ ")?.fg.toInts(),
280
- ).toEqual([128, 135, 162, 255])
281
- expect(active.spans.map((span) => span.bg.toInts())).toEqual(
282
- Array.from({ length: active.spans.length }, () => [73, 77, 100, 255]),
283
- )
284
- } finally {
285
- setup.renderer.destroy()
286
- }
287
- })
288
-
289
- test("renders entity and status Nerd Font icons in distinct colors", async () => {
290
- const setup = await testRender(
291
- () => (
292
- <InteractiveSelectPrompt
293
- prompt="Work on"
294
- choices={[
295
- {
296
- key: "verify",
297
- label: "[done] phase verify",
298
- depth: 0,
299
- segments: [
300
- { text: "󰄬", color: "#a6da95" },
301
- { text: " " },
302
- { text: "󰔚", color: "#eed49f" },
303
- { text: " verify" },
304
- ],
305
- },
306
- ]}
307
- onDone={() => undefined}
308
- />
309
- ),
310
- { width: 40, height: 4 },
311
- )
312
- try {
313
- await setup.renderer.setupTerminal()
314
- await setup.renderOnce()
315
- await Bun.sleep(0)
316
-
317
- expect(setup.captureCharFrame()).toContain("▌ ╰─ 󰄬 󰔚 verify")
318
- const spans = setup.captureSpans().lines.flatMap((line) => line.spans)
319
- expect(spans.find((span) => span.text === "󰄬")?.fg.toInts()).toEqual([
320
- 166, 218, 149, 255,
321
- ])
322
- expect(spans.find((span) => span.text === "󰔚")?.fg.toInts()).toEqual([
323
- 238, 212, 159, 255,
324
- ])
325
- } finally {
326
- setup.renderer.destroy()
327
- }
328
- })
329
-
330
- test("clears a non-empty filter before escape cancels", async () => {
331
- let selected: string | null | undefined
332
- const setup = await testRender(
333
- () => (
334
- <InteractiveSelectPrompt
335
- prompt="Work on"
336
- choices={[
337
- { key: "agency", label: "agency", depth: 0 },
338
- { key: "web", label: "web", depth: 0 },
339
- ]}
340
- onDone={(value) => {
341
- selected = value
342
- }}
343
- />
344
- ),
345
- { width: 40, height: 5, kittyKeyboard: true },
346
- )
347
- try {
348
- await setup.renderer.setupTerminal()
349
- await setup.renderOnce()
350
- await Bun.sleep(0)
351
- await setup.mockInput.typeText("web")
352
- await setup.flush()
353
- expect(setup.captureCharFrame()).toContain("▌ web")
354
-
355
- setup.mockInput.pressEscape()
356
- await setup.flush()
357
- expect(selected).toBeUndefined()
358
- const cleared = setup.captureCharFrame()
359
- expect(cleared).toContain("▌ ╭─ agency")
360
- expect(cleared).toContain(" ╰─ web")
361
-
362
- setup.mockInput.pressEscape()
363
- await setup.waitFor(() => selected !== undefined)
364
- expect(selected).toBeNull()
365
- } finally {
366
- setup.renderer.destroy()
367
- }
368
- })
369
-
370
- test("selects choices with ctrl-p, ctrl-n, and arrow navigation", async () => {
371
- const selectAfter = async (key: "p" | "n" | "up" | "down") => {
372
- let selected: string | null | undefined
373
- const setup = await testRender(
374
- () => (
375
- <InteractiveSelectPrompt
376
- prompt="Repository"
377
- choices={[
378
- { key: "agency", label: "agency" },
379
- { key: "web", label: "web" },
380
- { key: "docs", label: "docs" },
381
- ]}
382
- onDone={(value) => {
383
- selected = value
384
- }}
385
- />
386
- ),
387
- { width: 60, height: 4 },
388
- )
389
- try {
390
- await setup.renderer.setupTerminal()
391
- await setup.renderOnce()
392
- await Bun.sleep(0)
393
- if (key === "up" || key === "down") {
394
- setup.mockInput.pressArrow(key)
395
- } else {
396
- setup.mockInput.pressKey(key, { ctrl: true })
397
- }
398
- setup.mockInput.pressEnter()
399
- await setup.waitFor(() => selected !== undefined)
400
- return selected
401
- } finally {
402
- setup.renderer.destroy()
403
- }
404
- }
405
-
406
- expect(await selectAfter("p")).toBe("docs")
407
- expect(await selectAfter("n")).toBe("web")
408
- expect(await selectAfter("up")).toBe("docs")
409
- expect(await selectAfter("down")).toBe("web")
410
- })
411
-
412
- test("uses printable j, k, q, and ? characters as the fuzzy query", async () => {
413
- for (const query of ["j", "k", "q", "?"]) {
414
- let selected: string | null | undefined
415
- const setup = await testRender(
416
- () => (
417
- <InteractiveSelectPrompt
418
- prompt="Repository"
419
- choices={[
420
- { key: query, label: `${query} target` },
421
- { key: "other", label: "Other" },
422
- ]}
423
- onDone={(value) => {
424
- selected = value
425
- }}
426
- />
427
- ),
428
- { width: 60, height: 4 },
429
- )
430
- try {
431
- await setup.renderer.setupTerminal()
432
- await setup.renderOnce()
433
- await Bun.sleep(0)
434
- await setup.mockInput.typeText(query)
435
- setup.mockInput.pressEnter()
436
- await setup.waitFor(() => selected !== undefined)
437
- expect(selected).toBe(query)
438
- } finally {
439
- setup.renderer.destroy()
440
- }
441
- }
442
- })
443
-
444
- test("supports the readline movement and deletion contract", async () => {
445
- const cases = [
446
- {
447
- name: "left arrow",
448
- expected: "aXb",
449
- edit: async (input: MockInput) => {
450
- await input.typeText("ab")
451
- input.pressArrow("left")
452
- await input.typeText("X")
453
- },
454
- },
455
- {
456
- name: "home and end",
457
- expected: "XabY",
458
- edit: async (input: MockInput) => {
459
- await input.typeText("ab")
460
- input.pressKey(KeyCodes.HOME)
461
- await input.typeText("X")
462
- input.pressKey(KeyCodes.END)
463
- await input.typeText("Y")
464
- },
465
- },
466
- {
467
- name: "backspace and delete",
468
- expected: "a",
469
- edit: async (input: MockInput) => {
470
- await input.typeText("abc")
471
- input.pressBackspace()
472
- input.pressArrow("left")
473
- input.pressKey(KeyCodes.DELETE)
474
- },
475
- },
476
- {
477
- name: "ctrl-a and ctrl-e",
478
- expected: "XabY",
479
- edit: async (input: MockInput) => {
480
- await input.typeText("ab")
481
- input.pressKey("a", { ctrl: true })
482
- await input.typeText("X")
483
- input.pressKey("e", { ctrl: true })
484
- await input.typeText("Y")
485
- },
486
- },
487
- {
488
- name: "ctrl-b and ctrl-f",
489
- expected: "aXb",
490
- edit: async (input: MockInput) => {
491
- await input.typeText("ab")
492
- input.pressKey("b", { ctrl: true })
493
- input.pressKey("b", { ctrl: true })
494
- input.pressKey("f", { ctrl: true })
495
- await input.typeText("X")
496
- },
497
- },
498
- {
499
- name: "meta-b and meta-f",
500
- expected: "one Xtwo",
501
- edit: async (input: MockInput) => {
502
- await input.typeText("one two")
503
- input.pressKey("b", { meta: true })
504
- input.pressKey("b", { meta: true })
505
- input.pressKey("f", { meta: true })
506
- await input.typeText("X")
507
- },
508
- },
509
- {
510
- name: "ctrl-u",
511
- expected: "c",
512
- edit: async (input: MockInput) => {
513
- await input.typeText("abc")
514
- input.pressArrow("left")
515
- input.pressKey("u", { ctrl: true })
516
- },
517
- },
518
- {
519
- name: "ctrl-k",
520
- expected: "ab",
521
- edit: async (input: MockInput) => {
522
- await input.typeText("abc")
523
- input.pressArrow("left")
524
- input.pressKey("k", { ctrl: true })
525
- },
526
- },
527
- {
528
- name: "ctrl-w",
529
- expected: "one ",
530
- edit: async (input: MockInput) => {
531
- await input.typeText("one two")
532
- input.pressKey("w", { ctrl: true })
533
- },
534
- },
535
- {
536
- name: "ctrl-d",
537
- expected: "ab",
538
- edit: async (input: MockInput) => {
539
- await input.typeText("abc")
540
- input.pressArrow("left")
541
- input.pressKey("d", { ctrl: true })
542
- },
543
- },
544
- {
545
- name: "ctrl-h",
546
- expected: "ab",
547
- edit: async (input: MockInput) => {
548
- await input.typeText("abc")
549
- input.pressKey("h", { ctrl: true })
550
- },
551
- },
552
- ]
553
-
554
- for (const contract of cases) {
555
- expect(await submitEditedText(contract.edit), contract.name).toBe(
556
- contract.expected,
557
- )
558
- }
559
- })
560
-
561
- test("yanks the most recently killed text in both prompt inputs", async () => {
562
- expect(
563
- await submitEditedText(async (input) => {
564
- await input.typeText("one two")
565
- input.pressKey("w", { ctrl: true })
566
- input.pressKey("y", { ctrl: true })
567
- }),
568
- ).toBe("one two")
569
-
570
- let selected: string | null | undefined
571
- const setup = await testRender(
572
- () => (
573
- <InteractiveSelectPrompt
574
- prompt="Repository"
575
- choices={[
576
- { key: "agency", label: "agency" },
577
- { key: "web", label: "web" },
578
- ]}
579
- onDone={(value) => {
580
- selected = value
581
- }}
582
- />
583
- ),
584
- { width: 60, height: 4 },
585
- )
586
- try {
587
- await setup.renderer.setupTerminal()
588
- await setup.renderOnce()
589
- await Bun.sleep(0)
590
- await setup.mockInput.typeText("web")
591
- setup.mockInput.pressKey("u", { ctrl: true })
592
- setup.mockInput.pressKey("y", { ctrl: true })
593
- setup.mockInput.pressEnter()
594
- await setup.waitFor(() => selected !== undefined)
595
- expect(selected).toBe("web")
596
- } finally {
597
- setup.renderer.destroy()
598
- }
599
- })
600
-
601
- test("does not select an empty result and supports ctrl-u editing", async () => {
602
- let selected: string | null | undefined
603
- const setup = await testRender(
604
- () => (
605
- <InteractiveSelectPrompt
606
- prompt="Repository"
607
- choices={[
608
- { key: "agency", label: "agency" },
609
- { key: "web", label: "web" },
610
- ]}
611
- onDone={(value) => {
612
- selected = value
613
- }}
614
- />
615
- ),
616
- { width: 60, height: 4 },
617
- )
618
- try {
619
- await setup.renderer.setupTerminal()
620
- await setup.renderOnce()
621
- await Bun.sleep(0)
622
- await setup.mockInput.typeText("zzz")
623
- setup.mockInput.pressEnter()
624
- await Bun.sleep(0)
625
- expect(selected).toBeUndefined()
626
- setup.mockInput.pressKey("u", { ctrl: true })
627
- await setup.mockInput.typeText("web")
628
- setup.mockInput.pressEnter()
629
- await setup.waitFor(() => selected !== undefined)
630
- expect(selected).toBe("web")
631
- } finally {
632
- setup.renderer.destroy()
633
- }
634
- })
635
-
636
- test("resets and navigates selection after filtering", async () => {
637
- let selected: string | null | undefined
638
- const setup = await testRender(
639
- () => (
640
- <InteractiveSelectPrompt
641
- prompt="Repository"
642
- choices={[
643
- { key: "agency", label: "agency" },
644
- { key: "web-one", label: "web one" },
645
- { key: "web-two", label: "web two" },
646
- { key: "docs", label: "docs" },
647
- ]}
648
- onDone={(value) => {
649
- selected = value
650
- }}
651
- />
652
- ),
653
- { width: 60, height: 4 },
654
- )
655
- try {
656
- await setup.renderer.setupTerminal()
657
- await setup.renderOnce()
658
- await Bun.sleep(0)
659
- setup.mockInput.pressArrow("down")
660
- setup.mockInput.pressArrow("down")
661
- setup.mockInput.pressArrow("down")
662
- await setup.mockInput.typeText("web")
663
- setup.mockInput.pressKey("n", { ctrl: true })
664
- setup.mockInput.pressEnter()
665
- await setup.waitFor(() => selected !== undefined)
666
- expect(selected).toBe("web-two")
667
- } finally {
668
- setup.renderer.destroy()
669
- }
670
- })
671
-
672
- test("wraps text prompt input onto another line", async () => {
673
- const setup = await testRender(
674
- () => <InteractiveTextPrompt prompt="Task ID" onDone={() => {}} />,
675
- { width: 16, height: 5 },
676
- )
677
- try {
678
- await setup.renderer.setupTerminal()
679
- await setup.renderOnce()
680
- await Bun.sleep(0)
681
- await setup.mockInput.typeText("alpha beta gamma delta")
682
- await setup.flush()
683
- const frame = setup.captureCharFrame()
684
- expect(frame).toContain("alpha beta")
685
- expect(frame).toContain("gamma")
686
- expect(frame).toMatch(/alpha beta\s*\ngamma delta/)
687
- } finally {
688
- setup.renderer.destroy()
689
- }
690
- })
691
-
692
- test("keeps empty and single-item list boundaries safe", async () => {
693
- let emptyResult: string | null | undefined
694
- const empty = await testRender(
695
- () => (
696
- <InteractiveSelectPrompt
697
- prompt="Empty"
698
- choices={[]}
699
- onDone={(value) => {
700
- emptyResult = value
701
- }}
702
- />
703
- ),
704
- { width: 60, height: 4 },
705
- )
706
- try {
707
- await empty.renderer.setupTerminal()
708
- await empty.renderOnce()
709
- await Bun.sleep(0)
710
- empty.mockInput.pressArrow("up")
711
- empty.mockInput.pressArrow("down")
712
- empty.mockInput.pressKey("p", { ctrl: true })
713
- empty.mockInput.pressKey("n", { ctrl: true })
714
- empty.mockInput.pressEnter()
715
- await Bun.sleep(0)
716
- expect(emptyResult).toBeUndefined()
717
- expect(empty.captureCharFrame()).toContain("No matches")
718
- } finally {
719
- empty.renderer.destroy()
720
- }
721
-
722
- for (const move of ["up", "down"] as const) {
723
- let singleResult: string | null | undefined
724
- const single = await testRender(
725
- () => (
726
- <InteractiveSelectPrompt
727
- prompt="Single"
728
- choices={[{ key: "one", label: "One" }]}
729
- onDone={(value) => {
730
- singleResult = value
731
- }}
732
- />
733
- ),
734
- { width: 60, height: 4 },
735
- )
736
- try {
737
- await single.renderer.setupTerminal()
738
- await single.renderOnce()
739
- await Bun.sleep(0)
740
- single.mockInput.pressArrow(move)
741
- single.mockInput.pressEnter()
742
- await single.waitFor(() => singleResult !== undefined)
743
- expect(singleResult).toBe("one")
744
- } finally {
745
- single.renderer.destroy()
746
- }
747
- }
748
- })
749
-
750
- test("remains usable after a narrow terminal resize", async () => {
751
- let selected: string | null | undefined
752
- const setup = await testRender(
753
- () => (
754
- <InteractiveSelectPrompt
755
- prompt="Repository with a long prompt"
756
- choices={[
757
- { key: "agency", label: "agency" },
758
- { key: "docs", label: "docs" },
759
- ]}
760
- onDone={(value) => {
761
- selected = value
762
- }}
763
- />
764
- ),
765
- { width: 60, height: 4 },
766
- )
767
- try {
768
- await setup.renderer.setupTerminal()
769
- await setup.renderOnce()
770
- await Bun.sleep(0)
771
- setup.resize(18, 4)
772
- await setup.renderOnce()
773
- await setup.mockInput.typeText("docs")
774
- await setup.renderOnce()
775
- expect(setup.captureCharFrame()).toContain("▌ docs")
776
- setup.mockInput.pressEnter()
777
- await setup.waitFor(() => selected !== undefined)
778
- expect(selected).toBe("docs")
779
- } finally {
780
- setup.renderer.destroy()
781
- }
782
- })
783
-
784
- test("submits text and cancels with ctrl-c or escape", async () => {
785
- let submitted: string | null | undefined
786
- const input = await testRender(
787
- () => (
788
- <InteractiveTextPrompt
789
- prompt="Task ID"
790
- onDone={(value) => {
791
- submitted = value
792
- }}
793
- />
794
- ),
795
- { width: 60, height: 4 },
796
- )
797
- try {
798
- await input.renderer.setupTerminal()
799
- await input.renderOnce()
800
- await Bun.sleep(0)
801
- expect(input.renderer.keyInput.listenerCount("keypress")).toBeGreaterThan(
802
- 0,
803
- )
804
- await input.mockInput.typeText("improve-ui")
805
- input.mockInput.pressEnter()
806
- await input.waitFor(() => submitted !== undefined)
807
- expect(submitted).toBe("improve-ui")
808
- } finally {
809
- input.renderer.destroy()
810
- }
811
-
812
- let cancelled: string | null | undefined
813
- const select = await testRender(
814
- () => (
815
- <InteractiveSelectPrompt
816
- prompt="Cancel"
817
- choices={[{ key: "one", label: "One" }]}
818
- onDone={(value) => {
819
- cancelled = value
820
- }}
821
- />
822
- ),
823
- { width: 60, height: 4 },
824
- )
825
- try {
826
- await select.renderer.setupTerminal()
827
- await select.renderOnce()
828
- await Bun.sleep(0)
829
- select.mockInput.pressCtrlC()
830
- await select.waitFor(() => cancelled !== undefined)
831
- expect(cancelled).toBeNull()
832
- } finally {
833
- select.renderer.destroy()
834
- }
835
-
836
- let escaped: string | null | undefined
837
- const escape = await testRender(
838
- () => (
839
- <InteractiveSelectPrompt
840
- prompt="Cancel"
841
- choices={[{ key: "one", label: "One" }]}
842
- onDone={(value) => {
843
- escaped = value
844
- }}
845
- />
846
- ),
847
- { width: 60, height: 4, kittyKeyboard: true },
848
- )
849
- try {
850
- await escape.renderer.setupTerminal()
851
- await escape.renderOnce()
852
- await Bun.sleep(0)
853
- escape.mockInput.pressEscape()
854
- await escape.waitFor(() => escaped !== undefined)
855
- expect(escaped).toBeNull()
856
- } finally {
857
- escape.renderer.destroy()
858
- }
859
- })
860
- })