@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.
- package/README.md +5 -11
- package/package.json +1 -1
- package/src/cli.test.ts +71 -37
- package/src/commands/work.test.ts +10 -49
- package/src/commands/work.ts +1 -35
- 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/AGENTS.md +3 -4
- package/src/workbase/work-target.test.ts +27 -13
- package/src/workbase/work-target.ts +83 -28
|
@@ -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
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -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`.
|
|
81
|
-
|
|
82
|
-
|
|
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[
|
|
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
|
})
|