@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.34.1",
3
+ "version": "2.36.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
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: options.phaseStatus ?? "open",
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(() =>
@@ -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 and terminal-state guards
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 q characters as the fuzzy query", async () => {
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
- let value = ""
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
- value = input?.plainText ?? ""
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">enter submit | esc cancel</text>
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
- setQuery(input?.plainText ?? "")
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 or arrows
308
+ enter select | esc cancel | arrows/ctrl-n/p | ctrl-y yank
236
309
  </text>
237
310
  </box>
238
311
  )