@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 +1 -1
- package/src/utils/chooser.test.ts +47 -0
- package/src/utils/chooser.ts +15 -1
- package/src/utils/interactive.test.tsx +272 -1
- package/src/utils/interactive.tsx +155 -29
- package/src/utils/theme.ts +17 -0
- package/src/workbase/work-target.test.ts +27 -13
- package/src/workbase/work-target.ts +83 -28
package/package.json
CHANGED
|
@@ -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
|
|
package/src/utils/chooser.ts
CHANGED
|
@@ -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 {
|
|
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("
|
|
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 {
|
|
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
|
|
124
|
-
|
|
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=
|
|
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() -
|
|
257
|
-
Math.max(choices().length -
|
|
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 +
|
|
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
|
|
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=
|
|
357
|
+
<text fg={macchiato.blue} flexShrink={1} wrapMode="none">
|
|
271
358
|
{props.prompt}
|
|
272
359
|
</text>
|
|
273
|
-
<text fg=
|
|
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"
|
|
295
|
-
<For
|
|
296
|
-
{(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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=
|
|
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
|
-
...
|
|
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>(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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[
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"\x1b[
|
|
71
|
-
"\x1b[
|
|
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
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
`${
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
`${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
204
|
-
plainLabel: plainLabel("
|
|
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,
|