@markjaquith/agency 2.34.1 → 2.36.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/package.json +1 -1
- package/src/cli.test.ts +68 -0
- package/src/commands/work.test.ts +140 -4
- package/src/commands/work.ts +46 -1
- package/src/utils/interactive.pty.test.ts +128 -0
- package/src/utils/interactive.test.tsx +280 -2
- package/src/utils/interactive.tsx +79 -6
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -862,6 +862,19 @@ status: open
|
|
|
862
862
|
await runGit(["-C", source, "remote", "add", "origin", daemon.remote])
|
|
863
863
|
|
|
864
864
|
parseJson(await runCli(["init", root, "--json"], parent))
|
|
865
|
+
const agencyConfigPath = join(root, "agency.json")
|
|
866
|
+
const agencyConfig = JSON.parse(await Bun.file(agencyConfigPath).text())
|
|
867
|
+
await Bun.write(
|
|
868
|
+
agencyConfigPath,
|
|
869
|
+
`${JSON.stringify(
|
|
870
|
+
{
|
|
871
|
+
...agencyConfig,
|
|
872
|
+
runners: { noop: { command: ["true"] } },
|
|
873
|
+
},
|
|
874
|
+
null,
|
|
875
|
+
2,
|
|
876
|
+
)}\n`,
|
|
877
|
+
)
|
|
865
878
|
parseJson(
|
|
866
879
|
await runCli(["repo", "link", "agency", source, "--json"], root),
|
|
867
880
|
)
|
|
@@ -1066,6 +1079,61 @@ status: open
|
|
|
1066
1079
|
}
|
|
1067
1080
|
}
|
|
1068
1081
|
}
|
|
1082
|
+
|
|
1083
|
+
parseJson(
|
|
1084
|
+
await runCli(["task", "status", "example", "done", "--json"], root),
|
|
1085
|
+
)
|
|
1086
|
+
const blocked = await runCli(
|
|
1087
|
+
["work", "--task", "example", "--runner", "noop"],
|
|
1088
|
+
root,
|
|
1089
|
+
)
|
|
1090
|
+
expect(blocked.exitCode).toBe(1)
|
|
1091
|
+
expect(blocked.stderr).toContain("Task status is done")
|
|
1092
|
+
expect(
|
|
1093
|
+
parseJson(await runCli(["task", "show", "example", "--json"], root))
|
|
1094
|
+
.data.status,
|
|
1095
|
+
).toBe("done")
|
|
1096
|
+
|
|
1097
|
+
const resumedTask = await runCli(
|
|
1098
|
+
["work", "--task", "example", "--runner", "noop", "--force"],
|
|
1099
|
+
root,
|
|
1100
|
+
)
|
|
1101
|
+
expect(resumedTask).toMatchObject({ exitCode: 0, stderr: "" })
|
|
1102
|
+
expect(resumedTask.stdout).toContain(
|
|
1103
|
+
"Reopened task/example from done as working",
|
|
1104
|
+
)
|
|
1105
|
+
expect(
|
|
1106
|
+
parseJson(await runCli(["task", "show", "example", "--json"], root))
|
|
1107
|
+
.data.status,
|
|
1108
|
+
).toBe("working")
|
|
1109
|
+
|
|
1110
|
+
parseJson(
|
|
1111
|
+
await runCli(
|
|
1112
|
+
["phase", "status", "pipeline", "build", "dropped", "--json"],
|
|
1113
|
+
root,
|
|
1114
|
+
),
|
|
1115
|
+
)
|
|
1116
|
+
const resumedPhase = await runCli(
|
|
1117
|
+
[
|
|
1118
|
+
"work",
|
|
1119
|
+
"--task",
|
|
1120
|
+
"pipeline",
|
|
1121
|
+
"--phase",
|
|
1122
|
+
"build",
|
|
1123
|
+
"--runner",
|
|
1124
|
+
"noop",
|
|
1125
|
+
"--force",
|
|
1126
|
+
],
|
|
1127
|
+
root,
|
|
1128
|
+
)
|
|
1129
|
+
expect(resumedPhase).toMatchObject({ exitCode: 0, stderr: "" })
|
|
1130
|
+
expect(resumedPhase.stdout).toContain(
|
|
1131
|
+
"Reopened phase/pipeline/build from dropped as working",
|
|
1132
|
+
)
|
|
1133
|
+
const graph = parseJson(await runCli(["graph", "--json"], root))
|
|
1134
|
+
expect(
|
|
1135
|
+
graph.nodes.find((node: any) => node.id === "task:pipeline").status,
|
|
1136
|
+
).toBe("working")
|
|
1069
1137
|
},
|
|
1070
1138
|
30_000,
|
|
1071
1139
|
)
|
|
@@ -69,10 +69,17 @@ interface HarnessOptions {
|
|
|
69
69
|
readonly registeredWorkbases?: readonly string[]
|
|
70
70
|
readonly existingDirectories?: readonly string[]
|
|
71
71
|
readonly guardError?: Error
|
|
72
|
+
readonly launchError?: Error
|
|
72
73
|
readonly workTargetIds?: readonly string[]
|
|
73
74
|
readonly opencodeIntegrationState?: "managed" | "customized"
|
|
74
75
|
readonly taskStatus?: "open" | "working" | "delegated" | "done" | "dropped"
|
|
75
76
|
readonly phaseStatus?: "open" | "working" | "delegated" | "done" | "dropped"
|
|
77
|
+
readonly taskStatuses?: Readonly<
|
|
78
|
+
Record<string, "open" | "working" | "delegated" | "done" | "dropped">
|
|
79
|
+
>
|
|
80
|
+
readonly phaseStatuses?: Readonly<
|
|
81
|
+
Record<string, "open" | "working" | "delegated" | "done" | "dropped">
|
|
82
|
+
>
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -92,6 +99,8 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
92
99
|
const materializeOptions: Array<
|
|
93
100
|
Parameters<WorktreeService["materialize"]>[3]
|
|
94
101
|
> = []
|
|
102
|
+
const taskStatuses = { ...options.taskStatuses }
|
|
103
|
+
const phaseStatuses = { ...options.phaseStatuses }
|
|
95
104
|
const worktrees = {
|
|
96
105
|
materialize: (
|
|
97
106
|
_taskId: string,
|
|
@@ -147,13 +156,19 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
147
156
|
repo: "agency",
|
|
148
157
|
branch: `task/${id}`,
|
|
149
158
|
base: "main",
|
|
150
|
-
status: options.taskStatus ?? "open",
|
|
159
|
+
status: taskStatuses[id] ?? options.taskStatus ?? "open",
|
|
151
160
|
},
|
|
152
161
|
})
|
|
153
162
|
},
|
|
154
163
|
list: () => Effect.succeed(options.taskRecords ?? []),
|
|
155
164
|
setStatus: (id: string, status: string) => {
|
|
156
165
|
statusUpdates.push(`task:${id}:${status}`)
|
|
166
|
+
taskStatuses[id] = status as
|
|
167
|
+
| "open"
|
|
168
|
+
| "working"
|
|
169
|
+
| "delegated"
|
|
170
|
+
| "done"
|
|
171
|
+
| "dropped"
|
|
157
172
|
return Effect.void
|
|
158
173
|
},
|
|
159
174
|
}
|
|
@@ -167,12 +182,19 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
167
182
|
repo: "agency",
|
|
168
183
|
branch: `task/${id}`,
|
|
169
184
|
base: "main",
|
|
170
|
-
status:
|
|
185
|
+
status:
|
|
186
|
+
phaseStatuses[`${taskId}/${id}`] ?? options.phaseStatus ?? "open",
|
|
171
187
|
},
|
|
172
188
|
}),
|
|
173
189
|
list: () => Effect.succeed(options.phaseRecords ?? []),
|
|
174
190
|
setStatus: (taskId: string, id: string, status: string) => {
|
|
175
191
|
statusUpdates.push(`phase:${taskId}:${id}:${status}`)
|
|
192
|
+
phaseStatuses[`${taskId}/${id}`] = status as
|
|
193
|
+
| "open"
|
|
194
|
+
| "working"
|
|
195
|
+
| "delegated"
|
|
196
|
+
| "done"
|
|
197
|
+
| "dropped"
|
|
176
198
|
return Effect.void
|
|
177
199
|
},
|
|
178
200
|
}
|
|
@@ -241,6 +263,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
241
263
|
events.push(`launch:${cli}`)
|
|
242
264
|
launches.push({ cli, args, cwd })
|
|
243
265
|
launchEnvironments.push(environment)
|
|
266
|
+
if (options.launchError) throw options.launchError
|
|
244
267
|
}
|
|
245
268
|
const defaultPick: PickWorkTarget = () => Effect.succeed(null)
|
|
246
269
|
const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
|
|
@@ -284,6 +307,8 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
284
307
|
launchEnvironments,
|
|
285
308
|
materializeOptions,
|
|
286
309
|
statusUpdates,
|
|
310
|
+
taskStatuses,
|
|
311
|
+
phaseStatuses,
|
|
287
312
|
shownTasks,
|
|
288
313
|
progressUpdates,
|
|
289
314
|
guards,
|
|
@@ -328,6 +353,72 @@ describe("work command", () => {
|
|
|
328
353
|
})
|
|
329
354
|
})
|
|
330
355
|
|
|
356
|
+
test("reopens forced terminal tasks through open before launching as working", async () => {
|
|
357
|
+
for (const previousStatus of ["done", "dropped"] as const) {
|
|
358
|
+
const harness = createHarness({
|
|
359
|
+
taskStatuses: { example: previousStatus },
|
|
360
|
+
})
|
|
361
|
+
const output = await captureLogs(() =>
|
|
362
|
+
harness.run({ taskId: "example", opencode: true, force: true }),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
expect(harness.statusUpdates).toEqual([
|
|
366
|
+
"task:example:open",
|
|
367
|
+
"task:example:working",
|
|
368
|
+
])
|
|
369
|
+
expect(harness.taskStatuses.example).toBe("working")
|
|
370
|
+
expect(output).toEqual([
|
|
371
|
+
`Reopened task/example from ${previousStatus} as working`,
|
|
372
|
+
])
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test("reopens forced terminal phases through open before launching as working", async () => {
|
|
377
|
+
for (const previousStatus of ["done", "dropped"] as const) {
|
|
378
|
+
const harness = createHarness({
|
|
379
|
+
workspace: multiPhaseWorkspace,
|
|
380
|
+
multiPhaseTasks: ["example"],
|
|
381
|
+
phaseStatuses: { "example/implementation": previousStatus },
|
|
382
|
+
})
|
|
383
|
+
const output = await captureLogs(() =>
|
|
384
|
+
harness.run({
|
|
385
|
+
taskId: "example",
|
|
386
|
+
phaseId: "implementation",
|
|
387
|
+
opencode: true,
|
|
388
|
+
force: true,
|
|
389
|
+
}),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
expect(harness.statusUpdates).toEqual([
|
|
393
|
+
"phase:example:implementation:open",
|
|
394
|
+
"phase:example:implementation:working",
|
|
395
|
+
])
|
|
396
|
+
expect(harness.phaseStatuses["example/implementation"]).toBe("working")
|
|
397
|
+
expect(output).toEqual([
|
|
398
|
+
`Reopened phase/example/implementation from ${previousStatus} as working`,
|
|
399
|
+
])
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test("reports a forced reopen as one machine result", async () => {
|
|
404
|
+
const harness = createHarness({ taskStatuses: { example: "done" } })
|
|
405
|
+
const output = await captureLogs(() =>
|
|
406
|
+
harness.run({
|
|
407
|
+
taskId: "example",
|
|
408
|
+
opencode: true,
|
|
409
|
+
force: true,
|
|
410
|
+
json: true,
|
|
411
|
+
}),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
expect(JSON.parse(output.join("\n"))).toEqual({
|
|
415
|
+
target: "task/example",
|
|
416
|
+
reopened: true,
|
|
417
|
+
previousStatus: "done",
|
|
418
|
+
status: "working",
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
331
422
|
test("offers only launchable targets to the interactive chooser", async () => {
|
|
332
423
|
const harness = createHarness({
|
|
333
424
|
taskRecords: [
|
|
@@ -425,7 +516,7 @@ describe("work command", () => {
|
|
|
425
516
|
opencode: true,
|
|
426
517
|
})
|
|
427
518
|
|
|
428
|
-
expect(harness.shownTasks).toEqual(["delivery"])
|
|
519
|
+
expect(harness.shownTasks).toEqual(["delivery", "delivery"])
|
|
429
520
|
expect(harness.launches[0]?.cwd).toBe(taskDirectory)
|
|
430
521
|
})
|
|
431
522
|
|
|
@@ -438,7 +529,7 @@ describe("work command", () => {
|
|
|
438
529
|
opencode: true,
|
|
439
530
|
})
|
|
440
531
|
|
|
441
|
-
expect(harness.shownTasks).toEqual(["delivery"])
|
|
532
|
+
expect(harness.shownTasks).toEqual(["delivery", "delivery"])
|
|
442
533
|
expect(harness.launches[0]?.cwd).toBe(taskDirectory)
|
|
443
534
|
})
|
|
444
535
|
|
|
@@ -829,6 +920,22 @@ describe("work command", () => {
|
|
|
829
920
|
expect(printed.environment.API_TOKEN).toBeUndefined()
|
|
830
921
|
})
|
|
831
922
|
|
|
923
|
+
test("does not reopen a forced terminal target in print-only mode", async () => {
|
|
924
|
+
const harness = createHarness({ taskStatuses: { example: "done" } })
|
|
925
|
+
|
|
926
|
+
await captureLogs(() =>
|
|
927
|
+
harness.run({
|
|
928
|
+
taskId: "example",
|
|
929
|
+
opencode: true,
|
|
930
|
+
force: true,
|
|
931
|
+
printCommand: true,
|
|
932
|
+
}),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
expect(harness.statusUpdates).toEqual([])
|
|
936
|
+
expect(harness.taskStatuses.example).toBe("done")
|
|
937
|
+
})
|
|
938
|
+
|
|
832
939
|
test("prints the runtime OpenCode integration config", async () => {
|
|
833
940
|
const harness = createHarness()
|
|
834
941
|
const output = await captureLogs(() =>
|
|
@@ -933,6 +1040,35 @@ describe("work command", () => {
|
|
|
933
1040
|
])
|
|
934
1041
|
})
|
|
935
1042
|
|
|
1043
|
+
test("does not reopen a terminal target when preparation fails", async () => {
|
|
1044
|
+
const harness = createHarness({
|
|
1045
|
+
taskStatuses: { example: "dropped" },
|
|
1046
|
+
materializeError: new Error("materialization failed"),
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
await expect(
|
|
1050
|
+
harness.run({ taskId: "example", force: true }),
|
|
1051
|
+
).rejects.toThrow("materialization failed")
|
|
1052
|
+
expect(harness.statusUpdates).toEqual([])
|
|
1053
|
+
expect(harness.taskStatuses.example).toBe("dropped")
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
test("retains working when launch fails after a forced reopen", async () => {
|
|
1057
|
+
const harness = createHarness({
|
|
1058
|
+
taskStatuses: { example: "done" },
|
|
1059
|
+
launchError: new Error("launch failed"),
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
await expect(
|
|
1063
|
+
harness.run({ taskId: "example", force: true, silent: true }),
|
|
1064
|
+
).rejects.toThrow("launch failed")
|
|
1065
|
+
expect(harness.statusUpdates).toEqual([
|
|
1066
|
+
"task:example:open",
|
|
1067
|
+
"task:example:working",
|
|
1068
|
+
])
|
|
1069
|
+
expect(harness.taskStatuses.example).toBe("working")
|
|
1070
|
+
})
|
|
1071
|
+
|
|
936
1072
|
test("respects silent and verbose logging options", async () => {
|
|
937
1073
|
const verboseHarness = createHarness()
|
|
938
1074
|
const verboseLogs = await captureErrors(() =>
|
package/src/commands/work.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { IntegrationService } from "../services/IntegrationService"
|
|
|
12
12
|
import { createLoggers } from "../utils/effect"
|
|
13
13
|
import { execvp } from "../utils/exec"
|
|
14
14
|
import { createProgress, type Progress } from "../utils/progress"
|
|
15
|
+
import { isTerminalStatus } from "../readiness"
|
|
15
16
|
import {
|
|
16
17
|
buildWorkTargetChoices,
|
|
17
18
|
pickWorkTarget,
|
|
@@ -379,9 +380,53 @@ export const work = (
|
|
|
379
380
|
return
|
|
380
381
|
}
|
|
381
382
|
if (target.kind === "phase") {
|
|
383
|
+
const previousStatus = (yield* phases.show(
|
|
384
|
+
target.taskId,
|
|
385
|
+
target.phaseId,
|
|
386
|
+
root,
|
|
387
|
+
)).data.status
|
|
388
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
389
|
+
yield* phases.setStatus(target.taskId, target.phaseId, "open", root)
|
|
390
|
+
}
|
|
382
391
|
yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
|
|
392
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
393
|
+
const result = {
|
|
394
|
+
target: `phase/${target.taskId}/${target.phaseId}`,
|
|
395
|
+
reopened: true,
|
|
396
|
+
previousStatus,
|
|
397
|
+
status: "working",
|
|
398
|
+
}
|
|
399
|
+
log(
|
|
400
|
+
options.json
|
|
401
|
+
? JSON.stringify(result)
|
|
402
|
+
: `Reopened ${result.target} from ${previousStatus} as working`,
|
|
403
|
+
)
|
|
404
|
+
}
|
|
383
405
|
} else if (target.kind === "task" && !target.multiPhase) {
|
|
406
|
+
const task = yield* tasks.show(target.taskId, root)
|
|
407
|
+
if ("phases" in task.data) {
|
|
408
|
+
return yield* Effect.fail(
|
|
409
|
+
new Error(`Task '${target.taskId}' is not an execution unit`),
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
const previousStatus = task.data.status
|
|
413
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
414
|
+
yield* tasks.setStatus(target.taskId, "open", root)
|
|
415
|
+
}
|
|
384
416
|
yield* tasks.setStatus(target.taskId, "working", root)
|
|
417
|
+
if (options.force && isTerminalStatus(previousStatus)) {
|
|
418
|
+
const result = {
|
|
419
|
+
target: `task/${target.taskId}`,
|
|
420
|
+
reopened: true,
|
|
421
|
+
previousStatus,
|
|
422
|
+
status: "working",
|
|
423
|
+
}
|
|
424
|
+
log(
|
|
425
|
+
options.json
|
|
426
|
+
? JSON.stringify(result)
|
|
427
|
+
: `Reopened ${result.target} from ${previousStatus} as working`,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
385
430
|
}
|
|
386
431
|
for (const [key, value] of Object.entries(environment)) {
|
|
387
432
|
process.env[key] = value
|
|
@@ -484,7 +529,7 @@ Options:
|
|
|
484
529
|
--print-command Print cwd, argv, and non-secret environment without launch
|
|
485
530
|
--opencode Require the OpenCode preset
|
|
486
531
|
--claude Require the Claude Code preset
|
|
487
|
-
--force Override readiness
|
|
532
|
+
--force Override readiness; reopen terminal execution units
|
|
488
533
|
--no-input Never open an interactive selector
|
|
489
534
|
|
|
490
535
|
Without interactive input, provide an explicit workbase or cwd and an entity
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { cleanupTempDir, createTempDir } from "../test-utils"
|
|
4
|
+
|
|
5
|
+
const projectRoot = join(import.meta.dir, "../..")
|
|
6
|
+
const cliPath = join(projectRoot, "cli.ts")
|
|
7
|
+
const tempDirs: string[] = []
|
|
8
|
+
|
|
9
|
+
afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
|
|
10
|
+
|
|
11
|
+
const modes = (terminal: Bun.Terminal) => ({
|
|
12
|
+
input: terminal.inputFlags,
|
|
13
|
+
output: terminal.outputFlags,
|
|
14
|
+
local: terminal.localFlags,
|
|
15
|
+
control: terminal.controlFlags,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const waitFor = async (condition: () => boolean, output: () => string) => {
|
|
19
|
+
const deadline = Date.now() + 8_000
|
|
20
|
+
while (!condition()) {
|
|
21
|
+
if (Date.now() >= deadline) {
|
|
22
|
+
throw new Error(`Timed out waiting for terminal output:\n${output()}`)
|
|
23
|
+
}
|
|
24
|
+
await Bun.sleep(10)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const waitForExit = (subprocess: Bun.Subprocess, output: () => string) =>
|
|
29
|
+
new Promise<number>((resolve, reject) => {
|
|
30
|
+
const timeout = setTimeout(() => {
|
|
31
|
+
reject(new Error(`Timed out waiting for CLI exit:\n${output()}`))
|
|
32
|
+
}, 8_000)
|
|
33
|
+
subprocess.exited.then(
|
|
34
|
+
(exitCode) => {
|
|
35
|
+
clearTimeout(timeout)
|
|
36
|
+
resolve(exitCode)
|
|
37
|
+
},
|
|
38
|
+
(error) => {
|
|
39
|
+
clearTimeout(timeout)
|
|
40
|
+
reject(error)
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const createWorkbase = async () => {
|
|
46
|
+
const root = await createTempDir()
|
|
47
|
+
tempDirs.push(root)
|
|
48
|
+
const initialized = Bun.spawnSync(
|
|
49
|
+
[process.execPath, cliPath, "init", root, "--silent"],
|
|
50
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
51
|
+
)
|
|
52
|
+
if (initialized.exitCode !== 0) {
|
|
53
|
+
throw new Error(new TextDecoder().decode(initialized.stderr))
|
|
54
|
+
}
|
|
55
|
+
return root
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const runPrompt = async (
|
|
59
|
+
drive: (terminal: Bun.Terminal, output: () => string) => Promise<void>,
|
|
60
|
+
) => {
|
|
61
|
+
const root = await createWorkbase()
|
|
62
|
+
const decoder = new TextDecoder()
|
|
63
|
+
let output = ""
|
|
64
|
+
const terminal = new Bun.Terminal({
|
|
65
|
+
cols: 80,
|
|
66
|
+
rows: 24,
|
|
67
|
+
data: (_terminal, bytes) => {
|
|
68
|
+
output += decoder.decode(bytes, { stream: true })
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
const initialModes = modes(terminal)
|
|
72
|
+
const subprocess = Bun.spawn([process.execPath, cliPath, "task", "new"], {
|
|
73
|
+
cwd: root,
|
|
74
|
+
env: { ...process.env, TERM: "xterm-256color" },
|
|
75
|
+
terminal,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await waitFor(
|
|
80
|
+
() => output.includes("Task ID:"),
|
|
81
|
+
() => output,
|
|
82
|
+
)
|
|
83
|
+
const activeModes = modes(terminal)
|
|
84
|
+
expect(activeModes).not.toEqual(initialModes)
|
|
85
|
+
await drive(terminal, () => output)
|
|
86
|
+
const exitCode = await waitForExit(subprocess, () => output)
|
|
87
|
+
output += decoder.decode()
|
|
88
|
+
expect(exitCode).toBe(1)
|
|
89
|
+
expect(modes(terminal)).toEqual(initialModes)
|
|
90
|
+
expect(output.lastIndexOf("\x1b[?25h")).toBeGreaterThan(
|
|
91
|
+
output.lastIndexOf("\x1b[?25l"),
|
|
92
|
+
)
|
|
93
|
+
expect(output.lastIndexOf("\x1b[?2004l")).toBeGreaterThan(
|
|
94
|
+
output.lastIndexOf("\x1b[?2004h"),
|
|
95
|
+
)
|
|
96
|
+
return output
|
|
97
|
+
} finally {
|
|
98
|
+
if (subprocess.exitCode === null) {
|
|
99
|
+
subprocess.kill("SIGKILL")
|
|
100
|
+
await subprocess.exited
|
|
101
|
+
}
|
|
102
|
+
terminal.close()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("interactive CLI terminal restoration", () => {
|
|
107
|
+
test("restores terminal state after submission, resize, and escape", async () => {
|
|
108
|
+
const output = await runPrompt(async (terminal, currentOutput) => {
|
|
109
|
+
terminal.resize(30, 8)
|
|
110
|
+
terminal.write("pty-contract\r")
|
|
111
|
+
await waitFor(
|
|
112
|
+
() => currentOutput().includes("Ticket URL (optional):"),
|
|
113
|
+
currentOutput,
|
|
114
|
+
)
|
|
115
|
+
terminal.write("\x1b")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(output).toContain("Failed to read task input")
|
|
119
|
+
}, 12_000)
|
|
120
|
+
|
|
121
|
+
test("restores terminal state after ctrl-c", async () => {
|
|
122
|
+
const output = await runPrompt(async (terminal) => {
|
|
123
|
+
terminal.write("\x03")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
expect(output).toContain("Failed to read task input")
|
|
127
|
+
}, 12_000)
|
|
128
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { KeyCodes, type MockInput } from "@opentui/core/testing"
|
|
2
3
|
import { testRender } from "@opentui/solid"
|
|
3
4
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
4
5
|
import { tmpdir } from "node:os"
|
|
@@ -10,6 +11,34 @@ import {
|
|
|
10
11
|
interactiveRendererConfig,
|
|
11
12
|
} from "./interactive"
|
|
12
13
|
|
|
14
|
+
const submitEditedText = async (
|
|
15
|
+
edit: (input: MockInput) => void | Promise<void>,
|
|
16
|
+
) => {
|
|
17
|
+
let submitted: string | null | undefined
|
|
18
|
+
const setup = await testRender(
|
|
19
|
+
() => (
|
|
20
|
+
<InteractiveTextPrompt
|
|
21
|
+
prompt="Text"
|
|
22
|
+
onDone={(value) => {
|
|
23
|
+
submitted = value
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
26
|
+
),
|
|
27
|
+
{ width: 60, height: 4 },
|
|
28
|
+
)
|
|
29
|
+
try {
|
|
30
|
+
await setup.renderer.setupTerminal()
|
|
31
|
+
await setup.renderOnce()
|
|
32
|
+
await Bun.sleep(0)
|
|
33
|
+
await edit(setup.mockInput)
|
|
34
|
+
setup.mockInput.pressEnter()
|
|
35
|
+
await setup.waitFor(() => submitted !== undefined)
|
|
36
|
+
return submitted
|
|
37
|
+
} finally {
|
|
38
|
+
setup.renderer.destroy()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
describe("OpenTUI interaction", () => {
|
|
14
43
|
test("selects the Solid JSX runtime without the project preload", async () => {
|
|
15
44
|
const source = await Bun.file(
|
|
@@ -109,8 +138,8 @@ describe("OpenTUI interaction", () => {
|
|
|
109
138
|
expect(await selectAfter("down")).toBe("web")
|
|
110
139
|
})
|
|
111
140
|
|
|
112
|
-
test("uses printable j, k, and
|
|
113
|
-
for (const query of ["j", "k", "q"]) {
|
|
141
|
+
test("uses printable j, k, q, and ? characters as the fuzzy query", async () => {
|
|
142
|
+
for (const query of ["j", "k", "q", "?"]) {
|
|
114
143
|
let selected: string | null | undefined
|
|
115
144
|
const setup = await testRender(
|
|
116
145
|
() => (
|
|
@@ -141,6 +170,163 @@ describe("OpenTUI interaction", () => {
|
|
|
141
170
|
}
|
|
142
171
|
})
|
|
143
172
|
|
|
173
|
+
test("supports the readline movement and deletion contract", async () => {
|
|
174
|
+
const cases = [
|
|
175
|
+
{
|
|
176
|
+
name: "left arrow",
|
|
177
|
+
expected: "aXb",
|
|
178
|
+
edit: async (input: MockInput) => {
|
|
179
|
+
await input.typeText("ab")
|
|
180
|
+
input.pressArrow("left")
|
|
181
|
+
await input.typeText("X")
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "home and end",
|
|
186
|
+
expected: "XabY",
|
|
187
|
+
edit: async (input: MockInput) => {
|
|
188
|
+
await input.typeText("ab")
|
|
189
|
+
input.pressKey(KeyCodes.HOME)
|
|
190
|
+
await input.typeText("X")
|
|
191
|
+
input.pressKey(KeyCodes.END)
|
|
192
|
+
await input.typeText("Y")
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "backspace and delete",
|
|
197
|
+
expected: "a",
|
|
198
|
+
edit: async (input: MockInput) => {
|
|
199
|
+
await input.typeText("abc")
|
|
200
|
+
input.pressBackspace()
|
|
201
|
+
input.pressArrow("left")
|
|
202
|
+
input.pressKey(KeyCodes.DELETE)
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "ctrl-a and ctrl-e",
|
|
207
|
+
expected: "XabY",
|
|
208
|
+
edit: async (input: MockInput) => {
|
|
209
|
+
await input.typeText("ab")
|
|
210
|
+
input.pressKey("a", { ctrl: true })
|
|
211
|
+
await input.typeText("X")
|
|
212
|
+
input.pressKey("e", { ctrl: true })
|
|
213
|
+
await input.typeText("Y")
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "ctrl-b and ctrl-f",
|
|
218
|
+
expected: "aXb",
|
|
219
|
+
edit: async (input: MockInput) => {
|
|
220
|
+
await input.typeText("ab")
|
|
221
|
+
input.pressKey("b", { ctrl: true })
|
|
222
|
+
input.pressKey("b", { ctrl: true })
|
|
223
|
+
input.pressKey("f", { ctrl: true })
|
|
224
|
+
await input.typeText("X")
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "meta-b and meta-f",
|
|
229
|
+
expected: "one Xtwo",
|
|
230
|
+
edit: async (input: MockInput) => {
|
|
231
|
+
await input.typeText("one two")
|
|
232
|
+
input.pressKey("b", { meta: true })
|
|
233
|
+
input.pressKey("b", { meta: true })
|
|
234
|
+
input.pressKey("f", { meta: true })
|
|
235
|
+
await input.typeText("X")
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "ctrl-u",
|
|
240
|
+
expected: "c",
|
|
241
|
+
edit: async (input: MockInput) => {
|
|
242
|
+
await input.typeText("abc")
|
|
243
|
+
input.pressArrow("left")
|
|
244
|
+
input.pressKey("u", { ctrl: true })
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "ctrl-k",
|
|
249
|
+
expected: "ab",
|
|
250
|
+
edit: async (input: MockInput) => {
|
|
251
|
+
await input.typeText("abc")
|
|
252
|
+
input.pressArrow("left")
|
|
253
|
+
input.pressKey("k", { ctrl: true })
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "ctrl-w",
|
|
258
|
+
expected: "one ",
|
|
259
|
+
edit: async (input: MockInput) => {
|
|
260
|
+
await input.typeText("one two")
|
|
261
|
+
input.pressKey("w", { ctrl: true })
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "ctrl-d",
|
|
266
|
+
expected: "ab",
|
|
267
|
+
edit: async (input: MockInput) => {
|
|
268
|
+
await input.typeText("abc")
|
|
269
|
+
input.pressArrow("left")
|
|
270
|
+
input.pressKey("d", { ctrl: true })
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: "ctrl-h",
|
|
275
|
+
expected: "ab",
|
|
276
|
+
edit: async (input: MockInput) => {
|
|
277
|
+
await input.typeText("abc")
|
|
278
|
+
input.pressKey("h", { ctrl: true })
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
for (const contract of cases) {
|
|
284
|
+
expect(await submitEditedText(contract.edit), contract.name).toBe(
|
|
285
|
+
contract.expected,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test("yanks the most recently killed text in both prompt inputs", async () => {
|
|
291
|
+
expect(
|
|
292
|
+
await submitEditedText(async (input) => {
|
|
293
|
+
await input.typeText("one two")
|
|
294
|
+
input.pressKey("w", { ctrl: true })
|
|
295
|
+
input.pressKey("y", { ctrl: true })
|
|
296
|
+
}),
|
|
297
|
+
).toBe("one two")
|
|
298
|
+
|
|
299
|
+
let selected: string | null | undefined
|
|
300
|
+
const setup = await testRender(
|
|
301
|
+
() => (
|
|
302
|
+
<InteractiveSelectPrompt
|
|
303
|
+
prompt="Repository"
|
|
304
|
+
choices={[
|
|
305
|
+
{ key: "agency", label: "agency" },
|
|
306
|
+
{ key: "web", label: "web" },
|
|
307
|
+
]}
|
|
308
|
+
onDone={(value) => {
|
|
309
|
+
selected = value
|
|
310
|
+
}}
|
|
311
|
+
/>
|
|
312
|
+
),
|
|
313
|
+
{ width: 60, height: 4 },
|
|
314
|
+
)
|
|
315
|
+
try {
|
|
316
|
+
await setup.renderer.setupTerminal()
|
|
317
|
+
await setup.renderOnce()
|
|
318
|
+
await Bun.sleep(0)
|
|
319
|
+
await setup.mockInput.typeText("web")
|
|
320
|
+
setup.mockInput.pressKey("u", { ctrl: true })
|
|
321
|
+
setup.mockInput.pressKey("y", { ctrl: true })
|
|
322
|
+
setup.mockInput.pressEnter()
|
|
323
|
+
await setup.waitFor(() => selected !== undefined)
|
|
324
|
+
expect(selected).toBe("web")
|
|
325
|
+
} finally {
|
|
326
|
+
setup.renderer.destroy()
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
144
330
|
test("does not select an empty result and supports ctrl-u editing", async () => {
|
|
145
331
|
let selected: string | null | undefined
|
|
146
332
|
const setup = await testRender(
|
|
@@ -232,6 +418,98 @@ describe("OpenTUI interaction", () => {
|
|
|
232
418
|
}
|
|
233
419
|
})
|
|
234
420
|
|
|
421
|
+
test("keeps empty and single-item list boundaries safe", async () => {
|
|
422
|
+
let emptyResult: string | null | undefined
|
|
423
|
+
const empty = await testRender(
|
|
424
|
+
() => (
|
|
425
|
+
<InteractiveSelectPrompt
|
|
426
|
+
prompt="Empty"
|
|
427
|
+
choices={[]}
|
|
428
|
+
onDone={(value) => {
|
|
429
|
+
emptyResult = value
|
|
430
|
+
}}
|
|
431
|
+
/>
|
|
432
|
+
),
|
|
433
|
+
{ width: 60, height: 4 },
|
|
434
|
+
)
|
|
435
|
+
try {
|
|
436
|
+
await empty.renderer.setupTerminal()
|
|
437
|
+
await empty.renderOnce()
|
|
438
|
+
await Bun.sleep(0)
|
|
439
|
+
empty.mockInput.pressArrow("up")
|
|
440
|
+
empty.mockInput.pressArrow("down")
|
|
441
|
+
empty.mockInput.pressKey("p", { ctrl: true })
|
|
442
|
+
empty.mockInput.pressKey("n", { ctrl: true })
|
|
443
|
+
empty.mockInput.pressEnter()
|
|
444
|
+
await Bun.sleep(0)
|
|
445
|
+
expect(emptyResult).toBeUndefined()
|
|
446
|
+
expect(empty.captureCharFrame()).toContain("No matches")
|
|
447
|
+
} finally {
|
|
448
|
+
empty.renderer.destroy()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const move of ["up", "down"] as const) {
|
|
452
|
+
let singleResult: string | null | undefined
|
|
453
|
+
const single = await testRender(
|
|
454
|
+
() => (
|
|
455
|
+
<InteractiveSelectPrompt
|
|
456
|
+
prompt="Single"
|
|
457
|
+
choices={[{ key: "one", label: "One" }]}
|
|
458
|
+
onDone={(value) => {
|
|
459
|
+
singleResult = value
|
|
460
|
+
}}
|
|
461
|
+
/>
|
|
462
|
+
),
|
|
463
|
+
{ width: 60, height: 4 },
|
|
464
|
+
)
|
|
465
|
+
try {
|
|
466
|
+
await single.renderer.setupTerminal()
|
|
467
|
+
await single.renderOnce()
|
|
468
|
+
await Bun.sleep(0)
|
|
469
|
+
single.mockInput.pressArrow(move)
|
|
470
|
+
single.mockInput.pressEnter()
|
|
471
|
+
await single.waitFor(() => singleResult !== undefined)
|
|
472
|
+
expect(singleResult).toBe("one")
|
|
473
|
+
} finally {
|
|
474
|
+
single.renderer.destroy()
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test("remains usable after a narrow terminal resize", async () => {
|
|
480
|
+
let selected: string | null | undefined
|
|
481
|
+
const setup = await testRender(
|
|
482
|
+
() => (
|
|
483
|
+
<InteractiveSelectPrompt
|
|
484
|
+
prompt="Repository with a long prompt"
|
|
485
|
+
choices={[
|
|
486
|
+
{ key: "agency", label: "agency" },
|
|
487
|
+
{ key: "docs", label: "docs" },
|
|
488
|
+
]}
|
|
489
|
+
onDone={(value) => {
|
|
490
|
+
selected = value
|
|
491
|
+
}}
|
|
492
|
+
/>
|
|
493
|
+
),
|
|
494
|
+
{ width: 60, height: 4 },
|
|
495
|
+
)
|
|
496
|
+
try {
|
|
497
|
+
await setup.renderer.setupTerminal()
|
|
498
|
+
await setup.renderOnce()
|
|
499
|
+
await Bun.sleep(0)
|
|
500
|
+
setup.resize(18, 4)
|
|
501
|
+
await setup.renderOnce()
|
|
502
|
+
await setup.mockInput.typeText("docs")
|
|
503
|
+
await setup.renderOnce()
|
|
504
|
+
expect(setup.captureCharFrame()).toContain("> docs")
|
|
505
|
+
setup.mockInput.pressEnter()
|
|
506
|
+
await setup.waitFor(() => selected !== undefined)
|
|
507
|
+
expect(selected).toBe("docs")
|
|
508
|
+
} finally {
|
|
509
|
+
setup.renderer.destroy()
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
|
|
235
513
|
test("submits text and cancels with ctrl-c or escape", async () => {
|
|
236
514
|
let submitted: string | null | undefined
|
|
237
515
|
const input = await testRender(
|
|
@@ -34,9 +34,77 @@ interface PromptProps<T> {
|
|
|
34
34
|
const isCancel = (key: { name: string; ctrl: boolean }) =>
|
|
35
35
|
key.name === "escape" || (key.ctrl && key.name === "c")
|
|
36
36
|
|
|
37
|
+
interface PromptKey {
|
|
38
|
+
readonly name: string
|
|
39
|
+
readonly ctrl: boolean
|
|
40
|
+
preventDefault(): void
|
|
41
|
+
stopPropagation(): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const removedText = (before: string, after: string) => {
|
|
45
|
+
let start = 0
|
|
46
|
+
while (
|
|
47
|
+
start < before.length &&
|
|
48
|
+
start < after.length &&
|
|
49
|
+
before[start] === after[start]
|
|
50
|
+
) {
|
|
51
|
+
start++
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let end = 0
|
|
55
|
+
while (
|
|
56
|
+
before[before.length - end - 1] === after[after.length - end - 1] &&
|
|
57
|
+
end < before.length - start &&
|
|
58
|
+
end < after.length - start
|
|
59
|
+
) {
|
|
60
|
+
end++
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return before.slice(start, before.length - end)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const createReadlineEditing = (
|
|
67
|
+
getInput: () => TextareaRenderable | undefined,
|
|
68
|
+
onInput?: (value: string) => void,
|
|
69
|
+
) => {
|
|
70
|
+
let value = ""
|
|
71
|
+
let killBuffer = ""
|
|
72
|
+
let beforeKill: string | undefined
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
get value() {
|
|
76
|
+
return value
|
|
77
|
+
},
|
|
78
|
+
handleInput(next: string) {
|
|
79
|
+
if (beforeKill !== undefined) {
|
|
80
|
+
const killed = removedText(beforeKill, next)
|
|
81
|
+
if (killed) killBuffer = killed
|
|
82
|
+
beforeKill = undefined
|
|
83
|
+
}
|
|
84
|
+
value = next
|
|
85
|
+
onInput?.(next)
|
|
86
|
+
},
|
|
87
|
+
handleKey(key: PromptKey) {
|
|
88
|
+
const current = getInput()?.plainText
|
|
89
|
+
if (current !== undefined && current !== value) this.handleInput(current)
|
|
90
|
+
if (!key.ctrl) return false
|
|
91
|
+
if (key.name === "y") {
|
|
92
|
+
key.preventDefault()
|
|
93
|
+
key.stopPropagation()
|
|
94
|
+
if (killBuffer) getInput()?.insertText(killBuffer)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
if (key.name === "u" || key.name === "k" || key.name === "w") {
|
|
98
|
+
beforeKill = value
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
37
105
|
export const InteractiveTextPrompt = (props: PromptProps<string>) => {
|
|
38
106
|
let input: TextareaRenderable | undefined
|
|
39
|
-
|
|
107
|
+
const editing = createReadlineEditing(() => input)
|
|
40
108
|
useKeyboard((key) => {
|
|
41
109
|
if (isCancel(key)) {
|
|
42
110
|
key.preventDefault()
|
|
@@ -44,10 +112,11 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
|
|
|
44
112
|
props.onDone(null)
|
|
45
113
|
return
|
|
46
114
|
}
|
|
115
|
+
if (editing.handleKey(key)) return
|
|
47
116
|
if (key.name !== "return") return
|
|
48
117
|
key.preventDefault()
|
|
49
118
|
key.stopPropagation()
|
|
50
|
-
props.onDone(value)
|
|
119
|
+
props.onDone(editing.value)
|
|
51
120
|
})
|
|
52
121
|
|
|
53
122
|
return (
|
|
@@ -59,7 +128,7 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
|
|
|
59
128
|
wrapMode="word"
|
|
60
129
|
keyBindings={[{ name: "return", action: "submit" }]}
|
|
61
130
|
onContentChange={() => {
|
|
62
|
-
|
|
131
|
+
editing.handleInput(input?.plainText ?? "")
|
|
63
132
|
}}
|
|
64
133
|
ref={(next) => {
|
|
65
134
|
input = next
|
|
@@ -68,7 +137,9 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
|
|
|
68
137
|
})
|
|
69
138
|
}}
|
|
70
139
|
/>
|
|
71
|
-
<text fg="#6c7086"
|
|
140
|
+
<text fg="#6c7086" wrapMode="none">
|
|
141
|
+
enter submit | esc cancel | ctrl-y yank
|
|
142
|
+
</text>
|
|
72
143
|
</box>
|
|
73
144
|
)
|
|
74
145
|
}
|
|
@@ -145,6 +216,7 @@ export const fuzzyChoices = (
|
|
|
145
216
|
export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
|
|
146
217
|
let input: TextareaRenderable | undefined
|
|
147
218
|
const [query, setQuery] = createSignal("")
|
|
219
|
+
const editing = createReadlineEditing(() => input, setQuery)
|
|
148
220
|
const [selected, setSelected] = createSignal(0)
|
|
149
221
|
const choices = createMemo(() => fuzzyChoices(props.choices, query()))
|
|
150
222
|
const move = (offset: -1 | 1) => {
|
|
@@ -171,6 +243,7 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
|
|
|
171
243
|
move(1)
|
|
172
244
|
return
|
|
173
245
|
}
|
|
246
|
+
if (editing.handleKey(key)) return
|
|
174
247
|
if (key.name !== "return") return
|
|
175
248
|
key.preventDefault()
|
|
176
249
|
key.stopPropagation()
|
|
@@ -207,7 +280,7 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
|
|
|
207
280
|
placeholder="filter"
|
|
208
281
|
keyBindings={[{ name: "return", action: "submit" }]}
|
|
209
282
|
onContentChange={() => {
|
|
210
|
-
|
|
283
|
+
editing.handleInput(input?.plainText ?? "")
|
|
211
284
|
setSelected(0)
|
|
212
285
|
}}
|
|
213
286
|
ref={(next) => {
|
|
@@ -232,7 +305,7 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
|
|
|
232
305
|
</For>
|
|
233
306
|
</box>
|
|
234
307
|
<text fg="#6c7086" wrapMode="none">
|
|
235
|
-
enter select | esc cancel | ctrl-n/p
|
|
308
|
+
enter select | esc cancel | arrows/ctrl-n/p | ctrl-y yank
|
|
236
309
|
</text>
|
|
237
310
|
</box>
|
|
238
311
|
)
|