@markjaquith/agency 2.31.0 → 2.32.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.
- package/README.md +50 -38
- package/cli.ts +2 -1
- package/package.json +5 -2
- package/skills/agency/SKILL.md +7 -6
- package/skills/agency/references/commands.md +17 -14
- package/skills/agency/references/contracts.md +4 -5
- package/skills/agency/references/recipes.md +13 -10
- package/src/cli-parser.test.ts +12 -2
- package/src/cli-parser.ts +5 -2
- package/src/cli.test.ts +34 -10
- package/src/commands/doctor.test.ts +11 -1
- package/src/commands/phase.ts +1 -1
- package/src/commands/task.test.ts +34 -1
- package/src/commands/task.ts +16 -20
- package/src/commands/work.test.ts +65 -65
- package/src/commands/work.ts +43 -35
- package/src/opentui.d.ts +1 -0
- package/src/services/DoctorService.ts +22 -0
- package/src/services/IntegrationService.test.ts +2 -1
- package/src/services/PhaseService.ts +2 -2
- package/src/services/ReadinessService.test.ts +54 -0
- package/src/services/ReadinessService.ts +39 -2
- package/src/services/TaskPhaseService.test.ts +23 -9
- package/src/services/TaskService.ts +2 -2
- package/src/utils/chooser.test.ts +49 -12
- package/src/utils/chooser.ts +20 -37
- package/src/utils/interactive-loader.ts +6 -0
- package/src/utils/interactive.test.tsx +258 -0
- package/src/utils/interactive.tsx +290 -0
- package/src/workbase/AGENTS.md +5 -5
- package/src/workbase/runner-command.test.ts +30 -4
- package/src/workbase/runner-command.ts +21 -6
- package/src/workbase/schemas.test.ts +4 -2
- package/src/workbase/schemas.ts +4 -0
|
@@ -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 (
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
)
|
|
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 === "
|
|
255
|
+
if (validStatus === "delegated") {
|
|
256
256
|
return yield* new TaskError({
|
|
257
257
|
message:
|
|
258
|
-
"
|
|
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 & {
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
43
|
+
selections,
|
|
27
44
|
inputs,
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
describe("chooser", () => {
|
|
32
|
-
test("offers
|
|
33
|
-
|
|
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(
|
|
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
|
|
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,
|
|
83
|
+
await Effect.runPromise(choose("Pick one", choices, ["chooser"], io))
|
|
47
84
|
|
|
48
|
-
expect(io.
|
|
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({
|
|
110
|
+
const native = createIO({ select: async () => null })
|
|
74
111
|
const external = createIO({
|
|
75
112
|
run: async () => ({ exitCode: 130, stdout: "" }),
|
|
76
113
|
})
|
package/src/utils/chooser.ts
CHANGED
|
@@ -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
|
|
39
|
-
|
|
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.
|
|
55
|
+
outputIsTTY: Boolean(process.stdout.isTTY),
|
|
55
56
|
color:
|
|
56
|
-
Boolean(process.
|
|
57
|
+
Boolean(process.stdout.isTTY) &&
|
|
57
58
|
process.env.NO_COLOR === undefined &&
|
|
58
59
|
process.env.TERM !== "dumb",
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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,258 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { testRender } from "@opentui/solid"
|
|
3
|
+
import {
|
|
4
|
+
fuzzyChoices,
|
|
5
|
+
InteractiveSelectPrompt,
|
|
6
|
+
InteractiveTextPrompt,
|
|
7
|
+
interactiveRendererConfig,
|
|
8
|
+
} from "./interactive"
|
|
9
|
+
|
|
10
|
+
describe("OpenTUI interaction", () => {
|
|
11
|
+
test("uses the split-footer renderer contract", () => {
|
|
12
|
+
expect(interactiveRendererConfig).toMatchObject({
|
|
13
|
+
screenMode: "split-footer",
|
|
14
|
+
footerHeight: 4,
|
|
15
|
+
externalOutputMode: "capture-stdout",
|
|
16
|
+
clearOnShutdown: false,
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("ranks case-insensitive fuzzy matches", () => {
|
|
21
|
+
const choices = [
|
|
22
|
+
{ key: "nested", label: "Manage Agency" },
|
|
23
|
+
{ key: "prefix", label: "Agency" },
|
|
24
|
+
{ key: "other", label: "Website" },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
expect(fuzzyChoices(choices, "AG").map((choice) => choice.key)).toEqual([
|
|
28
|
+
"prefix",
|
|
29
|
+
"nested",
|
|
30
|
+
])
|
|
31
|
+
expect(fuzzyChoices(choices, "mgy").map((choice) => choice.key)).toEqual([
|
|
32
|
+
"nested",
|
|
33
|
+
])
|
|
34
|
+
expect(fuzzyChoices(choices, "zzz")).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("selects choices with ctrl-p, ctrl-n, and arrow navigation", async () => {
|
|
38
|
+
const selectAfter = async (key: "p" | "n" | "up" | "down") => {
|
|
39
|
+
let selected: string | null | undefined
|
|
40
|
+
const setup = await testRender(
|
|
41
|
+
() => (
|
|
42
|
+
<InteractiveSelectPrompt
|
|
43
|
+
prompt="Repository"
|
|
44
|
+
choices={[
|
|
45
|
+
{ key: "agency", label: "agency" },
|
|
46
|
+
{ key: "web", label: "web" },
|
|
47
|
+
{ key: "docs", label: "docs" },
|
|
48
|
+
]}
|
|
49
|
+
onDone={(value) => {
|
|
50
|
+
selected = value
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
),
|
|
54
|
+
{ width: 60, height: 4 },
|
|
55
|
+
)
|
|
56
|
+
try {
|
|
57
|
+
await setup.renderer.setupTerminal()
|
|
58
|
+
await setup.renderOnce()
|
|
59
|
+
await Bun.sleep(0)
|
|
60
|
+
if (key === "up" || key === "down") {
|
|
61
|
+
setup.mockInput.pressArrow(key)
|
|
62
|
+
} else {
|
|
63
|
+
setup.mockInput.pressKey(key, { ctrl: true })
|
|
64
|
+
}
|
|
65
|
+
setup.mockInput.pressEnter()
|
|
66
|
+
await setup.waitFor(() => selected !== undefined)
|
|
67
|
+
return selected
|
|
68
|
+
} finally {
|
|
69
|
+
setup.renderer.destroy()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
expect(await selectAfter("p")).toBe("docs")
|
|
74
|
+
expect(await selectAfter("n")).toBe("web")
|
|
75
|
+
expect(await selectAfter("up")).toBe("docs")
|
|
76
|
+
expect(await selectAfter("down")).toBe("web")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("uses printable j, k, and q characters as the fuzzy query", async () => {
|
|
80
|
+
for (const query of ["j", "k", "q"]) {
|
|
81
|
+
let selected: string | null | undefined
|
|
82
|
+
const setup = await testRender(
|
|
83
|
+
() => (
|
|
84
|
+
<InteractiveSelectPrompt
|
|
85
|
+
prompt="Repository"
|
|
86
|
+
choices={[
|
|
87
|
+
{ key: query, label: `${query} target` },
|
|
88
|
+
{ key: "other", label: "Other" },
|
|
89
|
+
]}
|
|
90
|
+
onDone={(value) => {
|
|
91
|
+
selected = value
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
),
|
|
95
|
+
{ width: 60, height: 4 },
|
|
96
|
+
)
|
|
97
|
+
try {
|
|
98
|
+
await setup.renderer.setupTerminal()
|
|
99
|
+
await setup.renderOnce()
|
|
100
|
+
await Bun.sleep(0)
|
|
101
|
+
await setup.mockInput.typeText(query)
|
|
102
|
+
setup.mockInput.pressEnter()
|
|
103
|
+
await setup.waitFor(() => selected !== undefined)
|
|
104
|
+
expect(selected).toBe(query)
|
|
105
|
+
} finally {
|
|
106
|
+
setup.renderer.destroy()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("does not select an empty result and supports ctrl-u editing", async () => {
|
|
112
|
+
let selected: string | null | undefined
|
|
113
|
+
const setup = await testRender(
|
|
114
|
+
() => (
|
|
115
|
+
<InteractiveSelectPrompt
|
|
116
|
+
prompt="Repository"
|
|
117
|
+
choices={[
|
|
118
|
+
{ key: "agency", label: "agency" },
|
|
119
|
+
{ key: "web", label: "web" },
|
|
120
|
+
]}
|
|
121
|
+
onDone={(value) => {
|
|
122
|
+
selected = value
|
|
123
|
+
}}
|
|
124
|
+
/>
|
|
125
|
+
),
|
|
126
|
+
{ width: 60, height: 4 },
|
|
127
|
+
)
|
|
128
|
+
try {
|
|
129
|
+
await setup.renderer.setupTerminal()
|
|
130
|
+
await setup.renderOnce()
|
|
131
|
+
await Bun.sleep(0)
|
|
132
|
+
await setup.mockInput.typeText("zzz")
|
|
133
|
+
setup.mockInput.pressEnter()
|
|
134
|
+
await Bun.sleep(0)
|
|
135
|
+
expect(selected).toBeUndefined()
|
|
136
|
+
setup.mockInput.pressKey("u", { ctrl: true })
|
|
137
|
+
await setup.mockInput.typeText("web")
|
|
138
|
+
setup.mockInput.pressEnter()
|
|
139
|
+
await setup.waitFor(() => selected !== undefined)
|
|
140
|
+
expect(selected).toBe("web")
|
|
141
|
+
} finally {
|
|
142
|
+
setup.renderer.destroy()
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("resets and navigates selection after filtering", async () => {
|
|
147
|
+
let selected: string | null | undefined
|
|
148
|
+
const setup = await testRender(
|
|
149
|
+
() => (
|
|
150
|
+
<InteractiveSelectPrompt
|
|
151
|
+
prompt="Repository"
|
|
152
|
+
choices={[
|
|
153
|
+
{ key: "agency", label: "agency" },
|
|
154
|
+
{ key: "web-one", label: "web one" },
|
|
155
|
+
{ key: "web-two", label: "web two" },
|
|
156
|
+
{ key: "docs", label: "docs" },
|
|
157
|
+
]}
|
|
158
|
+
onDone={(value) => {
|
|
159
|
+
selected = value
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
),
|
|
163
|
+
{ width: 60, height: 4 },
|
|
164
|
+
)
|
|
165
|
+
try {
|
|
166
|
+
await setup.renderer.setupTerminal()
|
|
167
|
+
await setup.renderOnce()
|
|
168
|
+
await Bun.sleep(0)
|
|
169
|
+
setup.mockInput.pressArrow("down")
|
|
170
|
+
setup.mockInput.pressArrow("down")
|
|
171
|
+
setup.mockInput.pressArrow("down")
|
|
172
|
+
await setup.mockInput.typeText("web")
|
|
173
|
+
setup.mockInput.pressKey("n", { ctrl: true })
|
|
174
|
+
setup.mockInput.pressEnter()
|
|
175
|
+
await setup.waitFor(() => selected !== undefined)
|
|
176
|
+
expect(selected).toBe("web-two")
|
|
177
|
+
} finally {
|
|
178
|
+
setup.renderer.destroy()
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("submits text and cancels with ctrl-c or escape", async () => {
|
|
183
|
+
let submitted: string | null | undefined
|
|
184
|
+
const input = await testRender(
|
|
185
|
+
() => (
|
|
186
|
+
<InteractiveTextPrompt
|
|
187
|
+
prompt="Task ID"
|
|
188
|
+
onDone={(value) => {
|
|
189
|
+
submitted = value
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
),
|
|
193
|
+
{ width: 60, height: 4 },
|
|
194
|
+
)
|
|
195
|
+
try {
|
|
196
|
+
await input.renderer.setupTerminal()
|
|
197
|
+
await input.renderOnce()
|
|
198
|
+
await Bun.sleep(0)
|
|
199
|
+
expect(input.renderer.keyInput.listenerCount("keypress")).toBeGreaterThan(
|
|
200
|
+
0,
|
|
201
|
+
)
|
|
202
|
+
await input.mockInput.typeText("improve-ui")
|
|
203
|
+
input.mockInput.pressEnter()
|
|
204
|
+
await input.waitFor(() => submitted !== undefined)
|
|
205
|
+
expect(submitted).toBe("improve-ui")
|
|
206
|
+
} finally {
|
|
207
|
+
input.renderer.destroy()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let cancelled: string | null | undefined
|
|
211
|
+
const select = await testRender(
|
|
212
|
+
() => (
|
|
213
|
+
<InteractiveSelectPrompt
|
|
214
|
+
prompt="Cancel"
|
|
215
|
+
choices={[{ key: "one", label: "One" }]}
|
|
216
|
+
onDone={(value) => {
|
|
217
|
+
cancelled = value
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
),
|
|
221
|
+
{ width: 60, height: 4 },
|
|
222
|
+
)
|
|
223
|
+
try {
|
|
224
|
+
await select.renderer.setupTerminal()
|
|
225
|
+
await select.renderOnce()
|
|
226
|
+
await Bun.sleep(0)
|
|
227
|
+
select.mockInput.pressCtrlC()
|
|
228
|
+
await select.waitFor(() => cancelled !== undefined)
|
|
229
|
+
expect(cancelled).toBeNull()
|
|
230
|
+
} finally {
|
|
231
|
+
select.renderer.destroy()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let escaped: string | null | undefined
|
|
235
|
+
const escape = await testRender(
|
|
236
|
+
() => (
|
|
237
|
+
<InteractiveSelectPrompt
|
|
238
|
+
prompt="Cancel"
|
|
239
|
+
choices={[{ key: "one", label: "One" }]}
|
|
240
|
+
onDone={(value) => {
|
|
241
|
+
escaped = value
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
),
|
|
245
|
+
{ width: 60, height: 4, kittyKeyboard: true },
|
|
246
|
+
)
|
|
247
|
+
try {
|
|
248
|
+
await escape.renderer.setupTerminal()
|
|
249
|
+
await escape.renderOnce()
|
|
250
|
+
await Bun.sleep(0)
|
|
251
|
+
escape.mockInput.pressEscape()
|
|
252
|
+
await escape.waitFor(() => escaped !== undefined)
|
|
253
|
+
expect(escaped).toBeNull()
|
|
254
|
+
} finally {
|
|
255
|
+
escape.renderer.destroy()
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
})
|