@markjaquith/agency 2.35.0 → 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.35.0",
3
+ "version": "2.36.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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
  )