@markjaquith/agency 2.31.0 → 2.32.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.
@@ -203,6 +203,60 @@ describe("ReadinessService", () => {
203
203
  await service((readiness) =>
204
204
  readiness.guardWorkTarget("phase:ship/implement", root),
205
205
  )
206
+ expect(
207
+ [
208
+ ...(await service((readiness) => readiness.getWorkTargetIds(root))),
209
+ ].sort(),
210
+ ).toEqual([...readyIds].sort())
211
+
212
+ await write(
213
+ root,
214
+ "tasks/ship/phases/implement/PHASE.md",
215
+ execution("working", "feat/implement"),
216
+ )
217
+ const resumableIds = await service((readiness) =>
218
+ readiness.getWorkTargetIds(root),
219
+ )
220
+ expect(resumableIds).toContain("epic:delivery")
221
+ expect(resumableIds).toContain("task:ship")
222
+ expect(resumableIds).toContain("execution-unit:phase/ship/implement")
223
+ await service((readiness) =>
224
+ readiness.guardWorkTarget("execution-unit:phase/ship/implement", root),
225
+ )
226
+
227
+ await write(
228
+ root,
229
+ "tasks/ship/phases/implement/PHASE.md",
230
+ `---
231
+ repo: agency
232
+ branch: feat/implement
233
+ base: main
234
+ pr: null
235
+ status: working
236
+ claim:
237
+ claimant: orchestrator
238
+ runner: opencode
239
+ sessionId: session-1
240
+ startedAt: 2026-07-20T00:00:00.000Z
241
+ targetRevision: ${"a".repeat(64)}
242
+ state: active
243
+ ---
244
+
245
+ # Execution
246
+ `,
247
+ )
248
+ expect(
249
+ await service((readiness) => readiness.getWorkTargetIds(root)),
250
+ ).not.toContain("execution-unit:phase/ship/implement")
251
+ await expect(
252
+ service((readiness) =>
253
+ readiness.guardWorkTarget(
254
+ "execution-unit:phase/ship/implement",
255
+ root,
256
+ true,
257
+ ),
258
+ ),
259
+ ).rejects.toThrow("active claim")
206
260
 
207
261
  const blocked = await service((readiness) =>
208
262
  Effect.either(readiness.guardWorkTarget("phase:ship/verify", root)),
@@ -46,6 +46,23 @@ const executionNodeId = (taskId: string, phaseId?: string) =>
46
46
  ? `execution-unit:phase/${taskId}/${phaseId}`
47
47
  : `execution-unit:task/${taskId}`
48
48
 
49
+ const hasActiveClaim = (node: GraphNode) =>
50
+ node.kind !== "epic" &&
51
+ node.kind !== "repository" &&
52
+ "claim" in node.data &&
53
+ node.data.claim?.state === "active"
54
+
55
+ const isResumableWork = (node: GraphNode) =>
56
+ node.kind !== "repository" &&
57
+ node.status === "working" &&
58
+ !hasActiveClaim(node) &&
59
+ node.readiness.blockers.every((blocker) => blocker.kind !== "validation")
60
+
61
+ const isWorkTarget = (node: GraphNode) =>
62
+ node.kind !== "repository" &&
63
+ !hasActiveClaim(node) &&
64
+ (node.readiness.ready || isResumableWork(node))
65
+
49
66
  const itemFor = (
50
67
  node: ExecutionNode,
51
68
  graph: AgencyGraph,
@@ -126,6 +143,15 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
126
143
  )
127
144
  }),
128
145
 
146
+ getWorkTargetIds: (cwd: string = process.cwd()) =>
147
+ Effect.gen(function* () {
148
+ const graphs = yield* GraphService
149
+ const graph = yield* graphs.get({ cwd })
150
+ return new Set(
151
+ graph.nodes.filter(isWorkTarget).map((node) => node.id),
152
+ )
153
+ }),
154
+
129
155
  getNext: (cwd: string = process.cwd(), select = false) =>
130
156
  Effect.gen(function* () {
131
157
  const graphs = yield* GraphService
@@ -150,11 +176,11 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
150
176
  override = false,
151
177
  ) =>
152
178
  Effect.gen(function* () {
153
- if (override) return
154
179
  const graphs = yield* GraphService
155
180
  const graph = yield* graphs.get({ cwd })
156
181
  const node = graph.nodes.find((candidate) => candidate.id === target)
157
182
  if (!node || !node.readiness) {
183
+ if (override) return
158
184
  return yield* new ExecutionGuardError({
159
185
  message: `Work target '${target}' was not found in the work graph.`,
160
186
  action: "work",
@@ -164,7 +190,18 @@ export class ReadinessService extends Effect.Service<ReadinessService>()(
164
190
  blockers: [],
165
191
  })
166
192
  }
167
- if (!node.readiness.ready) {
193
+ if (hasActiveClaim(node)) {
194
+ return yield* new ExecutionGuardError({
195
+ message: `Cannot work on '${node.key}': it has an active claim. Use agency release or agency finish first.`,
196
+ action: "work",
197
+ target,
198
+ status: node.status!,
199
+ blockedBy: node.readiness!.blockedBy,
200
+ blockers: node.readiness!.blockers,
201
+ })
202
+ }
203
+ if (override) return
204
+ if (!node.readiness.ready && !isResumableWork(node)) {
168
205
  const item = {
169
206
  key: node.key,
170
207
  status: node.status!,
@@ -301,17 +301,23 @@ describe("task and phase services", () => {
301
301
  ),
302
302
  )
303
303
  expect(createdTask.content).toContain("status: open")
304
- for (const status of ["working", "delegated"]) {
305
- await expect(
306
- runTestEffect(
307
- TaskService.pipe(
308
- Effect.flatMap((service) =>
309
- service.setStatus("single-status", status, root),
310
- ),
304
+ const workingTask = await runTestEffect(
305
+ TaskService.pipe(
306
+ Effect.flatMap((service) =>
307
+ service.setStatus("single-status", "working", root),
308
+ ),
309
+ ),
310
+ )
311
+ expect(workingTask.data.status).toBe("working")
312
+ await expect(
313
+ runTestEffect(
314
+ TaskService.pipe(
315
+ Effect.flatMap((service) =>
316
+ service.setStatus("single-status", "delegated", root),
311
317
  ),
312
318
  ),
313
- ).rejects.toThrow("require explicit ownership")
314
- }
319
+ ),
320
+ ).rejects.toThrow("Delegation requires explicit ownership")
315
321
  await runTestEffect(
316
322
  TaskService.pipe(
317
323
  Effect.flatMap((service) =>
@@ -359,6 +365,14 @@ describe("task and phase services", () => {
359
365
  ),
360
366
  ),
361
367
  )
368
+ const workingPhase = await runTestEffect(
369
+ PhaseService.pipe(
370
+ Effect.flatMap((service) =>
371
+ service.setStatus("multi-status", "implementation", "working", root),
372
+ ),
373
+ ),
374
+ )
375
+ expect(workingPhase.data.status).toBe("working")
362
376
  const phase = await runTestEffect(
363
377
  PhaseService.pipe(
364
378
  Effect.flatMap((service) =>
@@ -252,10 +252,10 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
252
252
  const fs = yield* FileSystemService
253
253
  const service = yield* TaskService
254
254
  const validStatus = yield* decodeStatus(status)
255
- if (validStatus === "working" || validStatus === "delegated") {
255
+ if (validStatus === "delegated") {
256
256
  return yield* new TaskError({
257
257
  message:
258
- "Active work and delegation require explicit ownership; use 'agency claim'",
258
+ "Delegation requires explicit ownership; use 'agency claim'",
259
259
  })
260
260
  }
261
261
  const record = yield* service.show(id, startPath)
@@ -9,43 +9,80 @@ const choices = [
9
9
 
10
10
  const createIO = (
11
11
  overrides: Partial<ChooserIO> = {},
12
- ): ChooserIO & { readonly writes: string[]; readonly inputs: string[] } => {
13
- const writes: string[] = []
12
+ ): ChooserIO & {
13
+ readonly selections: Array<{
14
+ readonly prompt: string
15
+ readonly choices: readonly {
16
+ readonly key: string
17
+ readonly label: string
18
+ }[]
19
+ }>
20
+ readonly inputs: string[]
21
+ } => {
22
+ const selections: Array<{
23
+ readonly prompt: string
24
+ readonly choices: readonly {
25
+ readonly key: string
26
+ readonly label: string
27
+ }[]
28
+ }> = []
14
29
  const inputs: string[] = []
15
30
  return {
16
31
  inputIsTTY: true,
17
32
  outputIsTTY: true,
18
33
  color: false,
19
- write: (message) => writes.push(message),
20
- question: async () => "1",
34
+ select: async (prompt, choices) => {
35
+ selections.push({ prompt, choices })
36
+ return choices[0]?.key ?? null
37
+ },
21
38
  run: async (_command, input) => {
22
39
  inputs.push(input)
23
40
  return { exitCode: 0, stdout: "first-key\n" }
24
41
  },
25
42
  ...overrides,
26
- writes,
43
+ selections,
27
44
  inputs,
28
45
  }
29
46
  }
30
47
 
31
48
  describe("chooser", () => {
32
- test("offers a plain-text numbered chooser on a TTY", async () => {
33
- const io = createIO({ question: async () => "2" })
49
+ test("offers plain choices to the native interactive renderer", async () => {
50
+ let offered:
51
+ | {
52
+ readonly prompt: string
53
+ readonly choices: readonly {
54
+ readonly key: string
55
+ readonly label: string
56
+ }[]
57
+ }
58
+ | undefined
59
+ const io = createIO({
60
+ select: async (prompt, offeredChoices) => {
61
+ offered = { prompt, choices: offeredChoices }
62
+ return offeredChoices[1]!.key
63
+ },
64
+ })
34
65
 
35
66
  const result = await Effect.runPromise(
36
67
  choose("Pick one", choices, undefined, io),
37
68
  )
38
69
 
39
70
  expect(result).toBe(2)
40
- expect(io.writes.join("")).toBe("Pick one\n 1. First\n 2. Second\n")
71
+ expect(offered).toEqual({
72
+ prompt: "Pick one",
73
+ choices: [
74
+ { key: "first-key", label: "First" },
75
+ { key: "second key", label: "Second" },
76
+ ],
77
+ })
41
78
  })
42
79
 
43
- test("preserves colors when enabled", async () => {
80
+ test("preserves colors for configured external choosers", async () => {
44
81
  const io = createIO({ color: true })
45
82
 
46
- await Effect.runPromise(choose("Pick one", choices, undefined, io))
83
+ await Effect.runPromise(choose("Pick one", choices, ["chooser"], io))
47
84
 
48
- expect(io.writes.join("")).toContain("\x1b[32mFirst\x1b[0m")
85
+ expect(io.inputs[0]).toContain("\x1b[32mFirst\x1b[0m")
49
86
  })
50
87
 
51
88
  test("passes generic records to an external argv command", async () => {
@@ -70,7 +107,7 @@ describe("chooser", () => {
70
107
  })
71
108
 
72
109
  test("treats native and external cancellation as no selection", async () => {
73
- const native = createIO({ question: async () => "q" })
110
+ const native = createIO({ select: async () => null })
74
111
  const external = createIO({
75
112
  run: async () => ({ exitCode: 130, stdout: "" }),
76
113
  })
@@ -1,5 +1,4 @@
1
1
  import { Effect } from "effect"
2
- import { createInterface } from "node:readline/promises"
3
2
 
4
3
  export interface Choice<T> {
5
4
  readonly key: string
@@ -35,8 +34,10 @@ export interface ChooserIO {
35
34
  readonly inputIsTTY: boolean
36
35
  readonly outputIsTTY: boolean
37
36
  readonly color: boolean
38
- readonly write: (message: string) => void
39
- readonly question: (prompt: string) => Promise<string>
37
+ readonly select: (
38
+ prompt: string,
39
+ choices: readonly { readonly key: string; readonly label: string }[],
40
+ ) => Promise<string | null>
40
41
  readonly run: (
41
42
  command: readonly string[],
42
43
  input: string,
@@ -51,22 +52,16 @@ const stripAnsi = (value: string) =>
51
52
 
52
53
  const defaultIO = (): ChooserIO => ({
53
54
  inputIsTTY: Boolean(process.stdin.isTTY),
54
- outputIsTTY: Boolean(process.stderr.isTTY),
55
+ outputIsTTY: Boolean(process.stdout.isTTY),
55
56
  color:
56
- Boolean(process.stderr.isTTY) &&
57
+ Boolean(process.stdout.isTTY) &&
57
58
  process.env.NO_COLOR === undefined &&
58
59
  process.env.TERM !== "dumb",
59
- write: (message) => process.stderr.write(message),
60
- question: async (prompt) => {
61
- const input = createInterface({
62
- input: process.stdin,
63
- output: process.stderr,
64
- })
65
- try {
66
- return await input.question(prompt)
67
- } finally {
68
- input.close()
69
- }
60
+ select: async (prompt, choices) => {
61
+ const { promptSelect } = await (
62
+ await import("./interactive-loader")
63
+ ).loadInteractive()
64
+ return promptSelect(prompt, choices)
70
65
  },
71
66
  run: async (command, input) => {
72
67
  const child = Bun.spawn([...command], {
@@ -173,33 +168,21 @@ const nativeChoice = async <T>(
173
168
  "Interactive selection requires a terminal; provide an explicit value or use --no-input",
174
169
  )
175
170
  }
176
- io.write(`${prompt}\n`)
177
- for (const [index, choice] of choices.entries()) {
178
- io.write(` ${index + 1}. ${displayLabel(choice, io.color)}\n`)
179
- }
180
- let answer: string
171
+ let key: string | null
181
172
  try {
182
- answer = (
183
- await io.question(`Select [1-${choices.length}] (q to cancel): `)
184
- ).trim()
173
+ key = await io.select(
174
+ prompt,
175
+ choices.map((choice) => ({
176
+ key: choice.key,
177
+ label: displayLabel(choice, false),
178
+ })),
179
+ )
185
180
  } catch (cause) {
186
- if (cause instanceof Error && cause.name === "AbortError") return null
187
181
  throw new ChooserError("input-unavailable", "Failed to read selection", {
188
182
  cause,
189
183
  })
190
184
  }
191
- if (!answer || answer.toLowerCase() === "q") return null
192
- if (!/^\d+$/.test(answer)) {
193
- throw new ChooserError("invalid-selection", `Invalid selection: ${answer}`)
194
- }
195
- const selected = choices[Number.parseInt(answer, 10) - 1]
196
- if (!selected) {
197
- throw new ChooserError(
198
- "invalid-selection",
199
- `Selection must be between 1 and ${choices.length}`,
200
- )
201
- }
202
- return selected.value
185
+ return key === null ? null : selectedChoice(key, choices)
203
186
  }
204
187
 
205
188
  export const choose = <T>(
@@ -0,0 +1,6 @@
1
+ export const loadInteractive = async () => {
2
+ // Resolve this at runtime so the Node-target bundle still selects Bun's preload.
3
+ const preload = ["@opentui", "solid", "preload"].join("/")
4
+ await import(preload)
5
+ return import("./interactive")
6
+ }
@@ -0,0 +1,291 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { testRender } from "@opentui/solid"
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
4
+ import { tmpdir } from "node:os"
5
+ import { dirname, join } from "node:path"
6
+ import {
7
+ fuzzyChoices,
8
+ InteractiveSelectPrompt,
9
+ InteractiveTextPrompt,
10
+ interactiveRendererConfig,
11
+ } from "./interactive"
12
+
13
+ describe("OpenTUI interaction", () => {
14
+ test("selects the Solid JSX runtime without the project preload", async () => {
15
+ const source = await Bun.file(
16
+ new URL("./interactive.tsx", import.meta.url),
17
+ ).text()
18
+ const root = await mkdtemp(join(tmpdir(), "agency-interactive-jsx-"))
19
+ const entrypoint = join(
20
+ root,
21
+ "node_modules",
22
+ "@markjaquith",
23
+ "agency",
24
+ "interactive.tsx",
25
+ )
26
+ await mkdir(dirname(entrypoint), { recursive: true })
27
+ await writeFile(entrypoint, source)
28
+
29
+ try {
30
+ const result = await Bun.build({
31
+ entrypoints: [entrypoint],
32
+ packages: "external",
33
+ target: "bun",
34
+ })
35
+ expect(result.success).toBeTrue()
36
+ const output = await result.outputs[0]!.text()
37
+ expect(output).toContain("@opentui/solid/jsx-dev-runtime")
38
+ expect(output).not.toContain("react/jsx-dev-runtime")
39
+ } finally {
40
+ await rm(root, { recursive: true, force: true })
41
+ }
42
+ })
43
+
44
+ test("uses the split-footer renderer contract", () => {
45
+ expect(interactiveRendererConfig).toMatchObject({
46
+ screenMode: "split-footer",
47
+ footerHeight: 4,
48
+ externalOutputMode: "capture-stdout",
49
+ clearOnShutdown: false,
50
+ })
51
+ })
52
+
53
+ test("ranks case-insensitive fuzzy matches", () => {
54
+ const choices = [
55
+ { key: "nested", label: "Manage Agency" },
56
+ { key: "prefix", label: "Agency" },
57
+ { key: "other", label: "Website" },
58
+ ]
59
+
60
+ expect(fuzzyChoices(choices, "AG").map((choice) => choice.key)).toEqual([
61
+ "prefix",
62
+ "nested",
63
+ ])
64
+ expect(fuzzyChoices(choices, "mgy").map((choice) => choice.key)).toEqual([
65
+ "nested",
66
+ ])
67
+ expect(fuzzyChoices(choices, "zzz")).toEqual([])
68
+ })
69
+
70
+ test("selects choices with ctrl-p, ctrl-n, and arrow navigation", async () => {
71
+ const selectAfter = async (key: "p" | "n" | "up" | "down") => {
72
+ let selected: string | null | undefined
73
+ const setup = await testRender(
74
+ () => (
75
+ <InteractiveSelectPrompt
76
+ prompt="Repository"
77
+ choices={[
78
+ { key: "agency", label: "agency" },
79
+ { key: "web", label: "web" },
80
+ { key: "docs", label: "docs" },
81
+ ]}
82
+ onDone={(value) => {
83
+ selected = value
84
+ }}
85
+ />
86
+ ),
87
+ { width: 60, height: 4 },
88
+ )
89
+ try {
90
+ await setup.renderer.setupTerminal()
91
+ await setup.renderOnce()
92
+ await Bun.sleep(0)
93
+ if (key === "up" || key === "down") {
94
+ setup.mockInput.pressArrow(key)
95
+ } else {
96
+ setup.mockInput.pressKey(key, { ctrl: true })
97
+ }
98
+ setup.mockInput.pressEnter()
99
+ await setup.waitFor(() => selected !== undefined)
100
+ return selected
101
+ } finally {
102
+ setup.renderer.destroy()
103
+ }
104
+ }
105
+
106
+ expect(await selectAfter("p")).toBe("docs")
107
+ expect(await selectAfter("n")).toBe("web")
108
+ expect(await selectAfter("up")).toBe("docs")
109
+ expect(await selectAfter("down")).toBe("web")
110
+ })
111
+
112
+ test("uses printable j, k, and q characters as the fuzzy query", async () => {
113
+ for (const query of ["j", "k", "q"]) {
114
+ let selected: string | null | undefined
115
+ const setup = await testRender(
116
+ () => (
117
+ <InteractiveSelectPrompt
118
+ prompt="Repository"
119
+ choices={[
120
+ { key: query, label: `${query} target` },
121
+ { key: "other", label: "Other" },
122
+ ]}
123
+ onDone={(value) => {
124
+ selected = value
125
+ }}
126
+ />
127
+ ),
128
+ { width: 60, height: 4 },
129
+ )
130
+ try {
131
+ await setup.renderer.setupTerminal()
132
+ await setup.renderOnce()
133
+ await Bun.sleep(0)
134
+ await setup.mockInput.typeText(query)
135
+ setup.mockInput.pressEnter()
136
+ await setup.waitFor(() => selected !== undefined)
137
+ expect(selected).toBe(query)
138
+ } finally {
139
+ setup.renderer.destroy()
140
+ }
141
+ }
142
+ })
143
+
144
+ test("does not select an empty result and supports ctrl-u editing", async () => {
145
+ let selected: string | null | undefined
146
+ const setup = await testRender(
147
+ () => (
148
+ <InteractiveSelectPrompt
149
+ prompt="Repository"
150
+ choices={[
151
+ { key: "agency", label: "agency" },
152
+ { key: "web", label: "web" },
153
+ ]}
154
+ onDone={(value) => {
155
+ selected = value
156
+ }}
157
+ />
158
+ ),
159
+ { width: 60, height: 4 },
160
+ )
161
+ try {
162
+ await setup.renderer.setupTerminal()
163
+ await setup.renderOnce()
164
+ await Bun.sleep(0)
165
+ await setup.mockInput.typeText("zzz")
166
+ setup.mockInput.pressEnter()
167
+ await Bun.sleep(0)
168
+ expect(selected).toBeUndefined()
169
+ setup.mockInput.pressKey("u", { ctrl: true })
170
+ await setup.mockInput.typeText("web")
171
+ setup.mockInput.pressEnter()
172
+ await setup.waitFor(() => selected !== undefined)
173
+ expect(selected).toBe("web")
174
+ } finally {
175
+ setup.renderer.destroy()
176
+ }
177
+ })
178
+
179
+ test("resets and navigates selection after filtering", async () => {
180
+ let selected: string | null | undefined
181
+ const setup = await testRender(
182
+ () => (
183
+ <InteractiveSelectPrompt
184
+ prompt="Repository"
185
+ choices={[
186
+ { key: "agency", label: "agency" },
187
+ { key: "web-one", label: "web one" },
188
+ { key: "web-two", label: "web two" },
189
+ { key: "docs", label: "docs" },
190
+ ]}
191
+ onDone={(value) => {
192
+ selected = value
193
+ }}
194
+ />
195
+ ),
196
+ { width: 60, height: 4 },
197
+ )
198
+ try {
199
+ await setup.renderer.setupTerminal()
200
+ await setup.renderOnce()
201
+ await Bun.sleep(0)
202
+ setup.mockInput.pressArrow("down")
203
+ setup.mockInput.pressArrow("down")
204
+ setup.mockInput.pressArrow("down")
205
+ await setup.mockInput.typeText("web")
206
+ setup.mockInput.pressKey("n", { ctrl: true })
207
+ setup.mockInput.pressEnter()
208
+ await setup.waitFor(() => selected !== undefined)
209
+ expect(selected).toBe("web-two")
210
+ } finally {
211
+ setup.renderer.destroy()
212
+ }
213
+ })
214
+
215
+ test("submits text and cancels with ctrl-c or escape", async () => {
216
+ let submitted: string | null | undefined
217
+ const input = await testRender(
218
+ () => (
219
+ <InteractiveTextPrompt
220
+ prompt="Task ID"
221
+ onDone={(value) => {
222
+ submitted = value
223
+ }}
224
+ />
225
+ ),
226
+ { width: 60, height: 4 },
227
+ )
228
+ try {
229
+ await input.renderer.setupTerminal()
230
+ await input.renderOnce()
231
+ await Bun.sleep(0)
232
+ expect(input.renderer.keyInput.listenerCount("keypress")).toBeGreaterThan(
233
+ 0,
234
+ )
235
+ await input.mockInput.typeText("improve-ui")
236
+ input.mockInput.pressEnter()
237
+ await input.waitFor(() => submitted !== undefined)
238
+ expect(submitted).toBe("improve-ui")
239
+ } finally {
240
+ input.renderer.destroy()
241
+ }
242
+
243
+ let cancelled: string | null | undefined
244
+ const select = await testRender(
245
+ () => (
246
+ <InteractiveSelectPrompt
247
+ prompt="Cancel"
248
+ choices={[{ key: "one", label: "One" }]}
249
+ onDone={(value) => {
250
+ cancelled = value
251
+ }}
252
+ />
253
+ ),
254
+ { width: 60, height: 4 },
255
+ )
256
+ try {
257
+ await select.renderer.setupTerminal()
258
+ await select.renderOnce()
259
+ await Bun.sleep(0)
260
+ select.mockInput.pressCtrlC()
261
+ await select.waitFor(() => cancelled !== undefined)
262
+ expect(cancelled).toBeNull()
263
+ } finally {
264
+ select.renderer.destroy()
265
+ }
266
+
267
+ let escaped: string | null | undefined
268
+ const escape = await testRender(
269
+ () => (
270
+ <InteractiveSelectPrompt
271
+ prompt="Cancel"
272
+ choices={[{ key: "one", label: "One" }]}
273
+ onDone={(value) => {
274
+ escaped = value
275
+ }}
276
+ />
277
+ ),
278
+ { width: 60, height: 4, kittyKeyboard: true },
279
+ )
280
+ try {
281
+ await escape.renderer.setupTerminal()
282
+ await escape.renderOnce()
283
+ await Bun.sleep(0)
284
+ escape.mockInput.pressEscape()
285
+ await escape.waitFor(() => escaped !== undefined)
286
+ expect(escaped).toBeNull()
287
+ } finally {
288
+ escape.renderer.destroy()
289
+ }
290
+ })
291
+ })