@markjaquith/agency 2.37.0 → 2.38.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.
@@ -6,9 +6,11 @@ import { tmpdir } from "node:os"
6
6
  import { dirname, join } from "node:path"
7
7
  import {
8
8
  fuzzyChoices,
9
+ hierarchyPrefix,
9
10
  InteractiveSelectPrompt,
10
11
  InteractiveTextPrompt,
11
12
  interactiveRendererConfig,
13
+ interactiveSelectRendererConfig,
12
14
  } from "./interactive"
13
15
 
14
16
  const submitEditedText = async (
@@ -44,6 +46,7 @@ describe("OpenTUI interaction", () => {
44
46
  const source = await Bun.file(
45
47
  new URL("./interactive.tsx", import.meta.url),
46
48
  ).text()
49
+ const theme = await Bun.file(new URL("./theme.ts", import.meta.url)).text()
47
50
  const root = await mkdtemp(join(tmpdir(), "agency-interactive-jsx-"))
48
51
  const entrypoint = join(
49
52
  root,
@@ -54,6 +57,7 @@ describe("OpenTUI interaction", () => {
54
57
  )
55
58
  await mkdir(dirname(entrypoint), { recursive: true })
56
59
  await writeFile(entrypoint, source)
60
+ await writeFile(join(dirname(entrypoint), "theme.ts"), theme)
57
61
 
58
62
  try {
59
63
  const result = await Bun.build({
@@ -79,6 +83,61 @@ describe("OpenTUI interaction", () => {
79
83
  })
80
84
  })
81
85
 
86
+ test("uses the full alternate screen for selectors", () => {
87
+ expect(interactiveSelectRendererConfig).toMatchObject({
88
+ screenMode: "alternate-screen",
89
+ externalOutputMode: "passthrough",
90
+ clearOnShutdown: false,
91
+ })
92
+ })
93
+
94
+ test("fills the available rows and keeps the selection visible after resize", async () => {
95
+ const setup = await testRender(
96
+ () => (
97
+ <InteractiveSelectPrompt
98
+ prompt="Work on"
99
+ choices={Array.from({ length: 10 }, (_, index) => ({
100
+ key: String(index),
101
+ label: `choice-${index}`,
102
+ }))}
103
+ onDone={() => undefined}
104
+ />
105
+ ),
106
+ { width: 40, height: 8 },
107
+ )
108
+ try {
109
+ await setup.renderer.setupTerminal()
110
+ await setup.renderOnce()
111
+ await Bun.sleep(0)
112
+
113
+ let frame = setup.captureCharFrame()
114
+ for (let index = 0; index < 5; index++) {
115
+ expect(frame).toContain(`choice-${index}`)
116
+ }
117
+ expect(frame).not.toContain("choice-5")
118
+
119
+ for (let index = 0; index < 7; index++) {
120
+ setup.mockInput.pressArrow("down")
121
+ }
122
+ await setup.flush()
123
+ frame = setup.captureCharFrame()
124
+ expect(frame).toContain("choice-5")
125
+ expect(frame).toContain("▌ choice-7")
126
+ expect(frame).toContain("choice-9")
127
+
128
+ setup.resize(40, 5)
129
+ await setup.flush()
130
+ frame = setup.captureCharFrame()
131
+ expect(frame).not.toContain("choice-5")
132
+ expect(frame).toContain("choice-6")
133
+ expect(frame).toContain("▌ choice-7")
134
+ expect(frame).not.toContain("choice-8")
135
+ expect(frame).not.toContain("choice-9")
136
+ } finally {
137
+ setup.renderer.destroy()
138
+ }
139
+ })
140
+
82
141
  test("ranks case-insensitive fuzzy matches", () => {
83
142
  const choices = [
84
143
  { key: "nested", label: "Manage Agency" },
@@ -96,6 +155,218 @@ describe("OpenTUI interaction", () => {
96
155
  expect(fuzzyChoices(choices, "zzz")).toEqual([])
97
156
  })
98
157
 
158
+ test("builds continuous hierarchy connectors for roots, children, and phases", () => {
159
+ const choices = [
160
+ { key: "epic", label: "epic delivery", depth: 0 },
161
+ { key: "multi", label: "task multi", depth: 1 },
162
+ { key: "build", label: "phase build", depth: 2 },
163
+ { key: "verify", label: "phase verify", depth: 2 },
164
+ { key: "single", label: "task single", depth: 1 },
165
+ { key: "empty", label: "epic empty", depth: 0 },
166
+ { key: "standalone", label: "task standalone", depth: 0 },
167
+ ]
168
+
169
+ expect(choices.map((_, index) => hierarchyPrefix(choices, index))).toEqual([
170
+ "╭─ ",
171
+ "│ ╭─ ",
172
+ "│ │ ╭─ ",
173
+ "│ │ ╰─ ",
174
+ "│ ╰─ ",
175
+ "├─ ",
176
+ "╰─ ",
177
+ ])
178
+ expect(hierarchyPrefix([{ key: "only", label: "Only", depth: 0 }], 0)).toBe(
179
+ "╰─ ",
180
+ )
181
+ expect(hierarchyPrefix([{ key: "flat", label: "Flat" }], 0)).toBe("")
182
+ })
183
+
184
+ test("renders hierarchy only while the filter is empty", async () => {
185
+ const setup = await testRender(
186
+ () => (
187
+ <InteractiveSelectPrompt
188
+ prompt="Work on"
189
+ choices={[
190
+ { key: "epic", label: "epic delivery", depth: 0 },
191
+ { key: "child", label: "task delivery-child", depth: 1 },
192
+ { key: "standalone", label: "task standalone", depth: 0 },
193
+ ]}
194
+ onDone={() => undefined}
195
+ />
196
+ ),
197
+ { width: 40, height: 5 },
198
+ )
199
+ try {
200
+ await setup.renderer.setupTerminal()
201
+ await setup.renderOnce()
202
+ await Bun.sleep(0)
203
+
204
+ let frame = setup.captureCharFrame()
205
+ expect(frame).toContain("▌ ╭─ epic delivery")
206
+ expect(frame).toContain(" │ ╰─ task delivery-child")
207
+
208
+ setup.mockInput.pressArrow("down")
209
+ await setup.flush()
210
+ frame = setup.captureCharFrame()
211
+ expect(frame).toContain(" ╭─ epic delivery")
212
+ expect(frame).toContain("▌ │ ╰─ task delivery-child")
213
+
214
+ await setup.mockInput.typeText("delivery")
215
+ await setup.flush()
216
+ frame = setup.captureCharFrame()
217
+ expect(frame).toContain("▌ epic delivery")
218
+ expect(frame).toContain(" task delivery-child")
219
+ expect(frame).not.toMatch(/[╭│├╰─]/)
220
+
221
+ setup.mockInput.pressKey("u", { ctrl: true })
222
+ await setup.flush()
223
+ setup.resize(32, 5)
224
+ await setup.flush()
225
+ frame = setup.captureCharFrame()
226
+ expect(frame).toContain("▌ ╭─ epic delivery")
227
+ expect(frame).toContain(" │ ╰─ task delivery-child")
228
+ } finally {
229
+ setup.renderer.destroy()
230
+ }
231
+ })
232
+
233
+ test("keeps connectors stable while highlighting the full active row", async () => {
234
+ const setup = await testRender(
235
+ () => (
236
+ <InteractiveSelectPrompt
237
+ prompt="Work on"
238
+ choices={[
239
+ { key: "agency", label: "agency", depth: 0 },
240
+ { key: "web", label: "web", depth: 0 },
241
+ ]}
242
+ onDone={() => undefined}
243
+ />
244
+ ),
245
+ { width: 40, height: 5 },
246
+ )
247
+ try {
248
+ await setup.renderer.setupTerminal()
249
+ await setup.renderOnce()
250
+ await Bun.sleep(0)
251
+
252
+ let lines = setup.captureSpans().lines
253
+ let active = lines.find((line) =>
254
+ line.spans.some((span) => span.text === "▌ "),
255
+ )!
256
+ expect(
257
+ active.spans.find((span) => span.text === "▌ ")?.fg.toInts(),
258
+ ).toEqual([198, 160, 246, 255])
259
+ expect(
260
+ active.spans.find((span) => span.text === "╭─ ")?.fg.toInts(),
261
+ ).toEqual([128, 135, 162, 255])
262
+ expect(active.spans.map((span) => span.bg.toInts())).toEqual(
263
+ Array.from({ length: active.spans.length }, () => [73, 77, 100, 255]),
264
+ )
265
+
266
+ setup.mockInput.pressArrow("down")
267
+ await setup.flush()
268
+ lines = setup.captureSpans().lines
269
+ active = lines.find((line) =>
270
+ line.spans.some((span) => span.text === "▌ "),
271
+ )!
272
+ expect(
273
+ lines
274
+ .flatMap((line) => line.spans)
275
+ .find((span) => span.text === "╭─ ")
276
+ ?.fg.toInts(),
277
+ ).toEqual([128, 135, 162, 255])
278
+ expect(
279
+ active.spans.find((span) => span.text === "╰─ ")?.fg.toInts(),
280
+ ).toEqual([128, 135, 162, 255])
281
+ expect(active.spans.map((span) => span.bg.toInts())).toEqual(
282
+ Array.from({ length: active.spans.length }, () => [73, 77, 100, 255]),
283
+ )
284
+ } finally {
285
+ setup.renderer.destroy()
286
+ }
287
+ })
288
+
289
+ test("renders entity and status Nerd Font icons in distinct colors", async () => {
290
+ const setup = await testRender(
291
+ () => (
292
+ <InteractiveSelectPrompt
293
+ prompt="Work on"
294
+ choices={[
295
+ {
296
+ key: "verify",
297
+ label: "[done] phase verify",
298
+ depth: 0,
299
+ segments: [
300
+ { text: "󰄬", color: "#a6da95" },
301
+ { text: " " },
302
+ { text: "󰔚", color: "#eed49f" },
303
+ { text: " verify" },
304
+ ],
305
+ },
306
+ ]}
307
+ onDone={() => undefined}
308
+ />
309
+ ),
310
+ { width: 40, height: 4 },
311
+ )
312
+ try {
313
+ await setup.renderer.setupTerminal()
314
+ await setup.renderOnce()
315
+ await Bun.sleep(0)
316
+
317
+ expect(setup.captureCharFrame()).toContain("▌ ╰─ 󰄬 󰔚 verify")
318
+ const spans = setup.captureSpans().lines.flatMap((line) => line.spans)
319
+ expect(spans.find((span) => span.text === "󰄬")?.fg.toInts()).toEqual([
320
+ 166, 218, 149, 255,
321
+ ])
322
+ expect(spans.find((span) => span.text === "󰔚")?.fg.toInts()).toEqual([
323
+ 238, 212, 159, 255,
324
+ ])
325
+ } finally {
326
+ setup.renderer.destroy()
327
+ }
328
+ })
329
+
330
+ test("clears a non-empty filter before escape cancels", async () => {
331
+ let selected: string | null | undefined
332
+ const setup = await testRender(
333
+ () => (
334
+ <InteractiveSelectPrompt
335
+ prompt="Work on"
336
+ choices={[
337
+ { key: "agency", label: "agency", depth: 0 },
338
+ { key: "web", label: "web", depth: 0 },
339
+ ]}
340
+ onDone={(value) => {
341
+ selected = value
342
+ }}
343
+ />
344
+ ),
345
+ { width: 40, height: 5, kittyKeyboard: true },
346
+ )
347
+ try {
348
+ await setup.renderer.setupTerminal()
349
+ await setup.renderOnce()
350
+ await Bun.sleep(0)
351
+ await setup.mockInput.typeText("web")
352
+ await setup.flush()
353
+ expect(setup.captureCharFrame()).toContain("▌ web")
354
+
355
+ setup.mockInput.pressEscape()
356
+ await setup.flush()
357
+ expect(selected).toBeUndefined()
358
+ const cleared = setup.captureCharFrame()
359
+ expect(cleared).toContain("▌ ╭─ agency")
360
+ expect(cleared).toContain(" ╰─ web")
361
+
362
+ setup.mockInput.pressEscape()
363
+ await setup.waitFor(() => selected !== undefined)
364
+ expect(selected).toBeNull()
365
+ } finally {
366
+ setup.renderer.destroy()
367
+ }
368
+ })
369
+
99
370
  test("selects choices with ctrl-p, ctrl-n, and arrow navigation", async () => {
100
371
  const selectAfter = async (key: "p" | "n" | "up" | "down") => {
101
372
  let selected: string | null | undefined
@@ -501,7 +772,7 @@ describe("OpenTUI interaction", () => {
501
772
  await setup.renderOnce()
502
773
  await setup.mockInput.typeText("docs")
503
774
  await setup.renderOnce()
504
- expect(setup.captureCharFrame()).toContain("> docs")
775
+ expect(setup.captureCharFrame()).toContain(" docs")
505
776
  setup.mockInput.pressEnter()
506
777
  await setup.waitFor(() => selected !== undefined)
507
778
  expect(selected).toBe("docs")
@@ -5,13 +5,23 @@ import {
5
5
  type CliRenderer,
6
6
  type CliRendererConfig,
7
7
  type TextareaRenderable,
8
+ type TextNodeOptions,
8
9
  } from "@opentui/core"
9
- import { render, useKeyboard, type JSX } from "@opentui/solid"
10
+ import {
11
+ render,
12
+ useKeyboard,
13
+ useTerminalDimensions,
14
+ type JSX,
15
+ } from "@opentui/solid"
10
16
  import { createMemo, createSignal, For } from "solid-js"
17
+ import type { ChoiceSegment } from "./chooser"
18
+ import { macchiato } from "./theme"
11
19
 
12
20
  export interface InteractiveChoice {
13
21
  readonly key: string
14
22
  readonly label: string
23
+ readonly depth?: number
24
+ readonly segments?: readonly ChoiceSegment[]
15
25
  }
16
26
 
17
27
  export const interactiveRendererConfig = {
@@ -26,6 +36,12 @@ export const interactiveRendererConfig = {
26
36
  openConsoleOnError: false,
27
37
  } satisfies CliRendererConfig
28
38
 
39
+ export const interactiveSelectRendererConfig = {
40
+ ...interactiveRendererConfig,
41
+ screenMode: "alternate-screen",
42
+ externalOutputMode: "passthrough",
43
+ } satisfies CliRendererConfig
44
+
29
45
  interface PromptProps<T> {
30
46
  readonly prompt: string
31
47
  readonly onDone: (value: T | null) => void
@@ -120,12 +136,22 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
120
136
  })
121
137
 
122
138
  return (
123
- <box flexDirection="column" width="100%" height="100%">
124
- <text fg="#7aa2f7">{props.prompt}</text>
139
+ <box
140
+ flexDirection="column"
141
+ width="100%"
142
+ height="100%"
143
+ backgroundColor={macchiato.base}
144
+ >
145
+ <text fg={macchiato.blue}>{props.prompt}</text>
125
146
  <textarea
126
147
  focused
127
148
  height={2}
128
149
  wrapMode="word"
150
+ backgroundColor={macchiato.mantle}
151
+ focusedBackgroundColor={macchiato.surface0}
152
+ textColor={macchiato.text}
153
+ focusedTextColor={macchiato.text}
154
+ cursorColor={macchiato.rosewater}
129
155
  keyBindings={[{ name: "return", action: "submit" }]}
130
156
  onContentChange={() => {
131
157
  editing.handleInput(input?.plainText ?? "")
@@ -137,7 +163,7 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
137
163
  })
138
164
  }}
139
165
  />
140
- <text fg="#6c7086" wrapMode="none">
166
+ <text fg={macchiato.overlay1} wrapMode="none">
141
167
  enter submit | esc cancel | ctrl-y yank
142
168
  </text>
143
169
  </box>
@@ -213,18 +239,72 @@ export const fuzzyChoices = (
213
239
  .map((match) => match.choice)
214
240
  }
215
241
 
242
+ const choiceDepth = (choice: InteractiveChoice) => choice.depth ?? 0
243
+
244
+ const hasSibling = (
245
+ choices: readonly InteractiveChoice[],
246
+ index: number,
247
+ direction: -1 | 1,
248
+ ) => {
249
+ const depth = choiceDepth(choices[index]!)
250
+ for (
251
+ let siblingIndex = index + direction;
252
+ siblingIndex >= 0 && siblingIndex < choices.length;
253
+ siblingIndex += direction
254
+ ) {
255
+ const siblingDepth = choiceDepth(choices[siblingIndex]!)
256
+ if (siblingDepth < depth) return false
257
+ if (siblingDepth === depth) return true
258
+ }
259
+ return false
260
+ }
261
+
262
+ export const hierarchyPrefix = (
263
+ choices: readonly InteractiveChoice[],
264
+ index: number,
265
+ ) => {
266
+ const choice = choices[index]
267
+ if (!choice || choice.depth === undefined) return ""
268
+ const depth = choiceDepth(choice)
269
+ let prefix = ""
270
+ let ancestorIndex = index
271
+
272
+ for (let ancestorDepth = depth - 1; ancestorDepth >= 0; ancestorDepth--) {
273
+ for (ancestorIndex--; ancestorIndex >= 0; ancestorIndex--) {
274
+ if (choiceDepth(choices[ancestorIndex]!) === ancestorDepth) break
275
+ }
276
+ prefix = `${ancestorIndex >= 0 && hasSibling(choices, ancestorIndex, 1) ? "│ " : " "}${prefix}`
277
+ }
278
+
279
+ const hasPrevious = hasSibling(choices, index, -1)
280
+ const hasNext = hasSibling(choices, index, 1)
281
+ return `${prefix}${!hasPrevious && hasNext ? "╭" : hasNext ? "├" : "╰"}─ `
282
+ }
283
+
216
284
  export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
217
285
  let input: TextareaRenderable | undefined
286
+ const dimensions = useTerminalDimensions()
218
287
  const [query, setQuery] = createSignal("")
219
288
  const editing = createReadlineEditing(() => input, setQuery)
220
289
  const [selected, setSelected] = createSignal(0)
221
290
  const choices = createMemo(() => fuzzyChoices(props.choices, query()))
291
+ const displaySegments = (choice: InteractiveChoice) =>
292
+ choice.segments ?? [{ text: choice.label }]
222
293
  const move = (offset: -1 | 1) => {
223
294
  const count = choices().length
224
295
  if (count === 0) return
225
296
  setSelected((current) => (current + offset + count) % count)
226
297
  }
227
298
  useKeyboard((key) => {
299
+ if (key.name === "escape" && query()) {
300
+ key.preventDefault()
301
+ key.stopPropagation()
302
+ input?.clear()
303
+ editing.handleInput("")
304
+ setQuery("")
305
+ setSelected(0)
306
+ return
307
+ }
228
308
  if (isCancel(key)) {
229
309
  key.preventDefault()
230
310
  key.stopPropagation()
@@ -252,25 +332,32 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
252
332
  })
253
333
 
254
334
  const visible = () => {
335
+ const visibleCount = Math.max(dimensions().height - 3, 1)
255
336
  const start = Math.min(
256
- Math.max(selected() - 1, 0),
257
- Math.max(choices().length - 2, 0),
337
+ Math.max(selected() - Math.floor(visibleCount / 2), 0),
338
+ Math.max(choices().length - visibleCount, 0),
258
339
  )
259
340
  return choices()
260
- .slice(start, start + 2)
341
+ .slice(start, start + visibleCount)
261
342
  .map((choice, offset) => ({
262
343
  choice,
263
344
  index: start + offset,
345
+ originalIndex: props.choices.indexOf(choice),
264
346
  }))
265
347
  }
266
348
 
267
349
  return (
268
- <box flexDirection="column" width="100%" height="100%">
350
+ <box
351
+ flexDirection="column"
352
+ width="100%"
353
+ height="100%"
354
+ backgroundColor={macchiato.base}
355
+ >
269
356
  <box flexDirection="row" width="100%">
270
- <text fg="#7aa2f7" flexShrink={1} wrapMode="none">
357
+ <text fg={macchiato.blue} flexShrink={1} wrapMode="none">
271
358
  {props.prompt}
272
359
  </text>
273
- <text fg="#7aa2f7">{" > "}</text>
360
+ <text fg={macchiato.blue}>{" > "}</text>
274
361
  <textarea
275
362
  focused
276
363
  flexGrow={1}
@@ -278,6 +365,12 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
278
365
  height={2}
279
366
  wrapMode="word"
280
367
  placeholder="filter"
368
+ placeholderColor={macchiato.overlay0}
369
+ backgroundColor={macchiato.mantle}
370
+ focusedBackgroundColor={macchiato.surface0}
371
+ textColor={macchiato.text}
372
+ focusedTextColor={macchiato.text}
373
+ cursorColor={macchiato.rosewater}
281
374
  keyBindings={[{ name: "return", action: "submit" }]}
282
375
  onContentChange={() => {
283
376
  editing.handleInput(input?.plainText ?? "")
@@ -291,21 +384,50 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
291
384
  }}
292
385
  />
293
386
  </box>
294
- <box flexDirection="column" height={2}>
295
- <For each={visible()} fallback={<text fg="#6c7086">No matches</text>}>
296
- {({ choice, index }) => (
297
- <text
298
- fg={index === selected() ? "#c0caf5" : "#6c7086"}
299
- wrapMode="none"
387
+ <box flexDirection="column" flexGrow={1}>
388
+ <For
389
+ each={visible()}
390
+ fallback={<text fg={macchiato.overlay1}>No matches</text>}
391
+ >
392
+ {({ choice, index, originalIndex }) => (
393
+ <box
394
+ width="100%"
395
+ height={1}
396
+ backgroundColor={
397
+ index === selected() ? macchiato.surface1 : macchiato.base
398
+ }
300
399
  >
301
- {index === selected() ? "> " : " "}
302
- {choice.label}
303
- </text>
400
+ <text
401
+ fg={index === selected() ? macchiato.text : macchiato.subtext0}
402
+ bg={index === selected() ? macchiato.surface1 : macchiato.base}
403
+ wrapMode="none"
404
+ >
405
+ <span
406
+ style={
407
+ {
408
+ fg: index === selected() ? macchiato.mauve : undefined,
409
+ } as TextNodeOptions
410
+ }
411
+ >
412
+ {index === selected() ? "▌ " : " "}
413
+ </span>
414
+ <span style={{ fg: macchiato.overlay1 } as TextNodeOptions}>
415
+ {query() ? "" : hierarchyPrefix(props.choices, originalIndex)}
416
+ </span>
417
+ <For each={displaySegments(choice)}>
418
+ {(segment) => (
419
+ <span style={{ fg: segment.color } as TextNodeOptions}>
420
+ {segment.text}
421
+ </span>
422
+ )}
423
+ </For>
424
+ </text>
425
+ </box>
304
426
  )}
305
427
  </For>
306
428
  </box>
307
- <text fg="#6c7086" wrapMode="none">
308
- enter select | esc cancel | arrows/ctrl-n/p | ctrl-y yank
429
+ <text fg={macchiato.overlay1} wrapMode="none">
430
+ enter select | esc clear/cancel | arrows/ctrl-n/p | ctrl-y yank
309
431
  </text>
310
432
  </box>
311
433
  )
@@ -323,6 +445,7 @@ const shutdown = async (renderer: CliRenderer) => {
323
445
 
324
446
  async function runInteractive<T>(
325
447
  view: (finish: (value: T | null) => void) => JSX.Element,
448
+ config: CliRendererConfig = interactiveRendererConfig,
326
449
  ) {
327
450
  let finish!: (value: T | null) => void
328
451
  let settled = false
@@ -336,7 +459,7 @@ async function runInteractive<T>(
336
459
  let renderer: CliRenderer | undefined
337
460
  try {
338
461
  renderer = await createCliRenderer({
339
- ...interactiveRendererConfig,
462
+ ...config,
340
463
  onDestroy: () => finish(null),
341
464
  })
342
465
  await render(() => view(finish), renderer)
@@ -361,10 +484,13 @@ export const promptSelect = (
361
484
  prompt: string,
362
485
  choices: readonly InteractiveChoice[],
363
486
  ) =>
364
- runInteractive<string>((finish) => (
365
- <InteractiveSelectPrompt
366
- prompt={prompt}
367
- choices={choices}
368
- onDone={finish}
369
- />
370
- ))
487
+ runInteractive<string>(
488
+ (finish) => (
489
+ <InteractiveSelectPrompt
490
+ prompt={prompt}
491
+ choices={choices}
492
+ onDone={finish}
493
+ />
494
+ ),
495
+ interactiveSelectRendererConfig,
496
+ )
@@ -0,0 +1,17 @@
1
+ export const macchiato = {
2
+ rosewater: "#f4dbd6",
3
+ red: "#ed8796",
4
+ yellow: "#eed49f",
5
+ green: "#a6da95",
6
+ sapphire: "#7dc4e4",
7
+ blue: "#8aadf4",
8
+ mauve: "#c6a0f6",
9
+ text: "#cad3f5",
10
+ subtext0: "#a5adcb",
11
+ overlay1: "#8087a2",
12
+ overlay0: "#6e738d",
13
+ surface1: "#494d64",
14
+ surface0: "#363a4f",
15
+ base: "#24273a",
16
+ mantle: "#1e2030",
17
+ } as const
@@ -77,7 +77,6 @@ files, and `agency work` reconciles them before launching an agent.
77
77
 
78
78
  OpenCode can access the complete workbase tree, but this filesystem permission
79
79
  does not expand Agency write authority beyond the checkout reported by
80
- `agency context`. Agency launches expose the managed OpenCode config file at
81
- runtime and inject absolute access rules scoped to this workbase. Direct edits
82
- remain denied outside the writable checkout, and no machine-specific path is
83
- stored in the Git-synced config.
80
+ `agency context`. OpenCode discovers the managed project config from task and
81
+ epic launch directories. No machine-specific path or runtime permission overlay
82
+ is required; agents must follow the authority reported by `agency context`.
@@ -61,14 +61,14 @@ describe("work target choices", () => {
61
61
  )
62
62
 
63
63
  expect(choices.map((choice) => choice.label)).toEqual([
64
- "\x1b[35m\x1b[0m delivery\x1b[2m - Ship the release\x1b[0m",
65
- " \x1b[36m󰗡\x1b[0m multi",
66
- " \x1b[34m◐\x1b[0m \x1b[33m󰔚\x1b[0m build",
67
- " \x1b[32m✓\x1b[0m \x1b[33m󰔚\x1b[0m verify",
68
- " \x1b[31m⊘\x1b[0m \x1b[33m󰔚\x1b[0m unlisted",
69
- " \x1b[32m✓\x1b[0m \x1b[36m󰗡\x1b[0m single",
70
- "\x1b[2m○\x1b[0m \x1b[36m󰗡\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
71
- "\x1b[35m↗\x1b[0m \x1b[36m󰗡\x1b[0m delegated",
64
+ "\x1b[38;2;198;160;246m\x1b[0m delivery\x1b[2m - Ship the release\x1b[0m",
65
+ "\x1b[38;2;125;196;228m󰗡\x1b[0m multi",
66
+ "\x1b[38;2;138;173;244m󰔟\x1b[0m \x1b[38;2;238;212;159m󰔚\x1b[0m build",
67
+ "\x1b[38;2;166;218;149m󰄬\x1b[0m \x1b[38;2;238;212;159m󰔚\x1b[0m verify",
68
+ "\x1b[38;2;237;135;150m󰅖\x1b[0m \x1b[38;2;238;212;159m󰔚\x1b[0m unlisted",
69
+ "\x1b[38;2;166;218;149m󰄬\x1b[0m \x1b[38;2;125;196;228m󰗡\x1b[0m single",
70
+ "\x1b[38;2;128;135;162m󰄱\x1b[0m \x1b[38;2;125;196;228m󰗡\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
71
+ "\x1b[38;2;198;160;246m󰁕\x1b[0m \x1b[38;2;125;196;228m󰗡\x1b[0m delegated",
72
72
  ])
73
73
  expect(choices.map((choice) => choice.target.kind)).toEqual([
74
74
  "epic",
@@ -82,13 +82,27 @@ describe("work target choices", () => {
82
82
  ])
83
83
  expect(choices.map((choice) => choice.plainLabel)).toEqual([
84
84
  "epic delivery - Ship the release",
85
- " task multi",
86
- " [working] phase build",
87
- " [done] phase verify",
88
- " [dropped] phase unlisted",
89
- " [done] task single",
85
+ "task multi",
86
+ "[working] phase build",
87
+ "[done] phase verify",
88
+ "[dropped] phase unlisted",
89
+ "[done] task single",
90
90
  "[open] task standalone - Independent work",
91
91
  "[delegated] task delegated",
92
92
  ])
93
+ expect(choices.map((choice) => choice.depth)).toEqual([
94
+ 0, 1, 2, 2, 2, 1, 0, 0,
95
+ ])
96
+ expect(choices[0]!.segments).toEqual([
97
+ { text: "", color: "#c6a0f6" },
98
+ { text: " delivery" },
99
+ { text: " - Ship the release", color: "#6e738d" },
100
+ ])
101
+ expect(choices[2]!.segments).toEqual([
102
+ { text: "󰔟", color: "#8aadf4" },
103
+ { text: " " },
104
+ { text: "󰔚", color: "#eed49f" },
105
+ { text: " build" },
106
+ ])
93
107
  })
94
108
  })