@markjaquith/agency 2.37.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.37.1",
3
+ "version": "2.38.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -77,6 +77,53 @@ describe("chooser", () => {
77
77
  })
78
78
  })
79
79
 
80
+ test("passes hierarchy depth only to the native renderer", async () => {
81
+ let offered: readonly {
82
+ readonly key: string
83
+ readonly label: string
84
+ readonly depth?: number
85
+ readonly segments?: readonly {
86
+ readonly text: string
87
+ readonly color?: string
88
+ }[]
89
+ }[] = []
90
+ const io = createIO({
91
+ select: async (_prompt, choices) => {
92
+ offered = choices
93
+ return choices[0]!.key
94
+ },
95
+ })
96
+
97
+ await Effect.runPromise(
98
+ choose(
99
+ "Pick one",
100
+ [
101
+ {
102
+ key: "parent",
103
+ label: "Parent",
104
+ depth: 0,
105
+ segments: [{ text: "P", color: "#c6a0f6" }],
106
+ value: 1,
107
+ },
108
+ { key: "child", label: "Child", depth: 1, value: 2 },
109
+ ],
110
+ undefined,
111
+ io,
112
+ ),
113
+ )
114
+
115
+ expect(offered).toEqual([
116
+ {
117
+ key: "parent",
118
+ label: "Parent",
119
+ depth: 0,
120
+ segments: [{ text: "P", color: "#c6a0f6" }],
121
+ },
122
+ { key: "child", label: "Child", depth: 1 },
123
+ ])
124
+ expect(io.inputs).toEqual([])
125
+ })
126
+
80
127
  test("preserves colors for configured external choosers", async () => {
81
128
  const io = createIO({ color: true })
82
129
 
@@ -4,9 +4,16 @@ export interface Choice<T> {
4
4
  readonly key: string
5
5
  readonly label: string
6
6
  readonly plainLabel?: string
7
+ readonly depth?: number
8
+ readonly segments?: readonly ChoiceSegment[]
7
9
  readonly value: T
8
10
  }
9
11
 
12
+ export interface ChoiceSegment {
13
+ readonly text: string
14
+ readonly color?: string
15
+ }
16
+
10
17
  export type ChooserErrorReason =
11
18
  | "invalid-choices"
12
19
  | "input-unavailable"
@@ -36,7 +43,12 @@ export interface ChooserIO {
36
43
  readonly color: boolean
37
44
  readonly select: (
38
45
  prompt: string,
39
- choices: readonly { readonly key: string; readonly label: string }[],
46
+ choices: readonly {
47
+ readonly key: string
48
+ readonly label: string
49
+ readonly depth?: number
50
+ readonly segments?: readonly ChoiceSegment[]
51
+ }[],
40
52
  ) => Promise<string | null>
41
53
  readonly run: (
42
54
  command: readonly string[],
@@ -175,6 +187,8 @@ const nativeChoice = async <T>(
175
187
  choices.map((choice) => ({
176
188
  key: choice.key,
177
189
  label: displayLabel(choice, false),
190
+ ...(choice.depth === undefined ? {} : { depth: choice.depth }),
191
+ ...(choice.segments === undefined ? {} : { segments: choice.segments }),
178
192
  })),
179
193
  )
180
194
  } catch (cause) {
@@ -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
@@ -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
  })
@@ -1,6 +1,7 @@
1
1
  import { Effect } from "effect"
2
2
  import type { WorkStatus } from "./schemas"
3
- import { choose } from "../utils/chooser"
3
+ import { choose, type ChoiceSegment } from "../utils/chooser"
4
+ import { macchiato } from "../utils/theme"
4
5
 
5
6
  export type WorkTarget =
6
7
  | {
@@ -56,58 +57,98 @@ interface PhaseRecord {
56
57
  export interface WorkTargetChoice {
57
58
  readonly label: string
58
59
  readonly plainLabel: string
60
+ readonly depth: number
61
+ readonly segments: readonly ChoiceSegment[]
59
62
  readonly target: WorkTarget
60
63
  }
61
64
 
62
- const statusIcons: Record<WorkStatus, string> = {
63
- open: "\x1b[2m○\x1b[0m",
64
- working: "\x1b[34m◐\x1b[0m",
65
- delegated: "\x1b[35m↗\x1b[0m",
66
- done: "\x1b[32m✓\x1b[0m",
67
- dropped: "\x1b[31m⊘\x1b[0m",
65
+ const statuses: Record<
66
+ WorkStatus,
67
+ { readonly icon: string; readonly color: string }
68
+ > = {
69
+ open: { icon: "󰄱", color: macchiato.overlay1 },
70
+ working: { icon: "󰔟", color: macchiato.blue },
71
+ delegated: { icon: "󰁕", color: macchiato.mauve },
72
+ done: { icon: "󰄬", color: macchiato.green },
73
+ dropped: { icon: "󰅖", color: macchiato.red },
68
74
  }
69
75
 
76
+ const kinds: Record<
77
+ WorkTarget["kind"],
78
+ { readonly icon: string; readonly color: string }
79
+ > = {
80
+ epic: { icon: "", color: macchiato.mauve },
81
+ task: { icon: "󰗡", color: macchiato.sapphire },
82
+ phase: { icon: "󰔚", color: macchiato.yellow },
83
+ }
84
+
85
+ const hexToAnsi = (color: string) => {
86
+ const value = color.slice(1)
87
+ return [0, 2, 4]
88
+ .map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16))
89
+ .join(";")
90
+ }
91
+
92
+ const colorize = (item: { readonly icon: string; readonly color: string }) =>
93
+ `\x1b[38;2;${hexToAnsi(item.color)}m${item.icon}\x1b[0m`
94
+
70
95
  const label = (
71
- indent: string,
72
96
  kind: WorkTarget["kind"],
73
97
  id: string,
74
98
  description?: string,
75
99
  status?: WorkStatus,
76
100
  ) =>
77
- `${indent}${status === undefined ? "" : `${statusIcons[status]} `}${
78
- {
79
- epic: "\x1b[35m\x1b[0m",
80
- task: "\x1b[36m󰗡\x1b[0m",
81
- phase: "\x1b[33m󰔚\x1b[0m",
82
- }[kind]
83
- } ${id}${description === undefined ? "" : `\x1b[2m - ${description}\x1b[0m`}`
101
+ `${status === undefined ? "" : `${colorize(statuses[status])} `}${colorize(kinds[kind])} ${id}${description === undefined ? "" : `\x1b[2m - ${description}\x1b[0m`}`
102
+
103
+ const segments = (
104
+ kind: WorkTarget["kind"],
105
+ id: string,
106
+ description?: string,
107
+ status?: WorkStatus,
108
+ ): readonly ChoiceSegment[] => [
109
+ ...(status === undefined
110
+ ? []
111
+ : [
112
+ { text: statuses[status].icon, color: statuses[status].color },
113
+ { text: " " },
114
+ ]),
115
+ { text: kinds[kind].icon, color: kinds[kind].color },
116
+ { text: ` ${id}` },
117
+ ...(description === undefined
118
+ ? []
119
+ : [{ text: ` - ${description}`, color: macchiato.overlay0 }]),
120
+ ]
84
121
 
85
122
  const plainLabel = (
86
- indent: string,
87
123
  kind: WorkTarget["kind"],
88
124
  id: string,
89
125
  description?: string,
90
126
  status?: WorkStatus,
91
127
  ) =>
92
- `${indent}${status === undefined ? "" : `[${status}] `}${kind} ${id}${description === undefined ? "" : ` - ${description}`}`
128
+ `${status === undefined ? "" : `[${status}] `}${kind} ${id}${description === undefined ? "" : ` - ${description}`}`
93
129
 
94
130
  const taskChoices = (
95
131
  task: TaskRecord,
96
132
  phaseRecords: readonly PhaseRecord[],
97
- indent: string,
133
+ depth: number,
98
134
  ): readonly WorkTargetChoice[] => {
99
135
  const multiPhase = "phases" in task.data
100
136
  const choices: WorkTargetChoice[] = [
101
137
  {
102
138
  label: label(
103
- indent,
104
139
  "task",
105
140
  task.id,
106
141
  task.data.description,
107
142
  multiPhase ? undefined : (task.data.status ?? "open"),
108
143
  ),
109
144
  plainLabel: plainLabel(
110
- indent,
145
+ "task",
146
+ task.id,
147
+ task.data.description,
148
+ multiPhase ? undefined : (task.data.status ?? "open"),
149
+ ),
150
+ depth,
151
+ segments: segments(
111
152
  "task",
112
153
  task.id,
113
154
  task.data.description,
@@ -132,14 +173,19 @@ const taskChoices = (
132
173
  renderedPhases.add(record.id)
133
174
  choices.push({
134
175
  label: label(
135
- `${indent} `,
136
176
  "phase",
137
177
  record.id,
138
178
  record.data.description,
139
179
  record.data.status ?? "open",
140
180
  ),
141
181
  plainLabel: plainLabel(
142
- `${indent} `,
182
+ "phase",
183
+ record.id,
184
+ record.data.description,
185
+ record.data.status ?? "open",
186
+ ),
187
+ depth: depth + 1,
188
+ segments: segments(
143
189
  "phase",
144
190
  record.id,
145
191
  record.data.description,
@@ -158,14 +204,19 @@ const taskChoices = (
158
204
  if (renderedPhases.has(record.id)) continue
159
205
  choices.push({
160
206
  label: label(
161
- `${indent} `,
162
207
  "phase",
163
208
  record.id,
164
209
  record.data.description,
165
210
  record.data.status ?? "open",
166
211
  ),
167
212
  plainLabel: plainLabel(
168
- `${indent} `,
213
+ "phase",
214
+ record.id,
215
+ record.data.description,
216
+ record.data.status ?? "open",
217
+ ),
218
+ depth: depth + 1,
219
+ segments: segments(
169
220
  "phase",
170
221
  record.id,
171
222
  record.data.description,
@@ -200,21 +251,23 @@ export const buildWorkTargetChoices = (
200
251
 
201
252
  for (const epic of epicRecords) {
202
253
  choices.push({
203
- label: label("", "epic", epic.id, epic.data.description),
204
- plainLabel: plainLabel("", "epic", epic.id, epic.data.description),
254
+ label: label("epic", epic.id, epic.data.description),
255
+ plainLabel: plainLabel("epic", epic.id, epic.data.description),
256
+ depth: 0,
257
+ segments: segments("epic", epic.id, epic.data.description),
205
258
  target: { kind: "epic", epicId: epic.id, path: epic.path },
206
259
  })
207
260
  for (const child of epic.data.tasks) {
208
261
  const task = tasks.get(child.id)
209
262
  if (!task || nestedTasks.has(task.id)) continue
210
263
  nestedTasks.add(task.id)
211
- choices.push(...taskChoices(task, phases.get(task.id) ?? [], " "))
264
+ choices.push(...taskChoices(task, phases.get(task.id) ?? [], 1))
212
265
  }
213
266
  }
214
267
 
215
268
  for (const task of taskRecords) {
216
269
  if (nestedTasks.has(task.id)) continue
217
- choices.push(...taskChoices(task, phases.get(task.id) ?? [], ""))
270
+ choices.push(...taskChoices(task, phases.get(task.id) ?? [], 0))
218
271
  }
219
272
 
220
273
  return choices
@@ -232,6 +285,8 @@ export const pickWorkTarget: PickWorkTarget = (choices, command) =>
232
285
  key: String(index),
233
286
  label: choice.label,
234
287
  plainLabel: choice.plainLabel,
288
+ depth: choice.depth,
289
+ segments: choice.segments,
235
290
  value: choice.target,
236
291
  })),
237
292
  command,