@markjaquith/agency 3.2.14 → 3.3.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 (75) hide show
  1. package/README.md +20 -3
  2. package/cli-main.ts +1 -0
  3. package/package.json +7 -2
  4. package/src/cli-parser.ts +13 -0
  5. package/src/commands/work.ts +70 -2
  6. package/src/services/ReadinessService.ts +20 -0
  7. package/src/workbase/execution-contract.ts +10 -1
  8. package/src/workbase/schemas.ts +12 -0
  9. package/fixtures/protocol/orchestration-recipes.json +0 -53
  10. package/pi-extensions/agency.test.ts +0 -117
  11. package/src/check-commits.test.ts +0 -58
  12. package/src/cli-parser.test.ts +0 -945
  13. package/src/cli.test.ts +0 -1889
  14. package/src/commands/act.test.ts +0 -418
  15. package/src/commands/archive.test.ts +0 -236
  16. package/src/commands/context.test.ts +0 -711
  17. package/src/commands/description.test.ts +0 -103
  18. package/src/commands/doctor.test.ts +0 -185
  19. package/src/commands/epic.test.ts +0 -196
  20. package/src/commands/init.test.ts +0 -117
  21. package/src/commands/integration.test.ts +0 -165
  22. package/src/commands/pr.test.ts +0 -248
  23. package/src/commands/push.test.ts +0 -56
  24. package/src/commands/read-only.test.ts +0 -161
  25. package/src/commands/repo.test.ts +0 -199
  26. package/src/commands/restore.test.ts +0 -106
  27. package/src/commands/status.test.ts +0 -113
  28. package/src/commands/sync.test.ts +0 -200
  29. package/src/commands/task-phase.test.ts +0 -410
  30. package/src/commands/task.test.ts +0 -281
  31. package/src/commands/validate.test.ts +0 -186
  32. package/src/commands/work.test.ts +0 -1375
  33. package/src/commands/workbase.test.ts +0 -170
  34. package/src/graph-schema.test.ts +0 -124
  35. package/src/protocol.test.ts +0 -163
  36. package/src/readiness.test.ts +0 -105
  37. package/src/services/ArchiveBulkService.test.ts +0 -384
  38. package/src/services/ArchiveService.test.ts +0 -1092
  39. package/src/services/EpicService.test.ts +0 -74
  40. package/src/services/FileSystemService.test.ts +0 -81
  41. package/src/services/GraphMutationService.test.ts +0 -486
  42. package/src/services/GraphService.test.ts +0 -494
  43. package/src/services/IntegrationService.test.ts +0 -1091
  44. package/src/services/LifecycleTransaction.test.ts +0 -145
  45. package/src/services/PullRequestService.test.ts +0 -716
  46. package/src/services/PushService.test.ts +0 -408
  47. package/src/services/ReadinessService.test.ts +0 -290
  48. package/src/services/RepositoryService.test.ts +0 -789
  49. package/src/services/ReviewService.test.ts +0 -408
  50. package/src/services/SyncService.test.ts +0 -1377
  51. package/src/services/TaskPhaseService.test.ts +0 -852
  52. package/src/services/VersionControlService.test.ts +0 -62
  53. package/src/services/WorkbaseService.test.ts +0 -910
  54. package/src/services/WorktreeLock.test.ts +0 -205
  55. package/src/services/WorktreePerformance.test.ts +0 -71
  56. package/src/services/WorktreeService.test.ts +0 -2292
  57. package/src/test-utils.ts +0 -110
  58. package/src/usage-log.test.ts +0 -188
  59. package/src/utils/chooser.test.ts +0 -192
  60. package/src/utils/effect.test.ts +0 -30
  61. package/src/utils/interactive.pty.test.ts +0 -128
  62. package/src/utils/interactive.test.tsx +0 -860
  63. package/src/utils/process.test.ts +0 -132
  64. package/src/utils/progress.test.ts +0 -37
  65. package/src/work-view.test.ts +0 -151
  66. package/src/workbase/agent-command.test.ts +0 -128
  67. package/src/workbase/checkout-command.test.ts +0 -62
  68. package/src/workbase/delivery-command.test.ts +0 -180
  69. package/src/workbase/dependency-graph.test.ts +0 -50
  70. package/src/workbase/execution-contract.test.ts +0 -161
  71. package/src/workbase/frontmatter.test.ts +0 -67
  72. package/src/workbase/repository-reference.test.ts +0 -25
  73. package/src/workbase/schemas.test.ts +0 -543
  74. package/src/workbase/work-target.test.ts +0 -108
  75. 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
- })