@bastani/atomic 0.5.3-1 → 0.5.4-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 +110 -11
- package/dist/{chunk-mn870nrv.js → chunk-xkxndz5g.js} +213 -154
- package/dist/sdk/components/workflow-picker-panel.d.ts +120 -0
- package/dist/sdk/define-workflow.d.ts +1 -1
- package/dist/sdk/index.js +1 -1
- package/dist/sdk/runtime/discovery.d.ts +57 -3
- package/dist/sdk/runtime/executor.d.ts +15 -2
- package/dist/sdk/runtime/tmux.d.ts +9 -0
- package/dist/sdk/types.d.ts +63 -4
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +61 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +25 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +91 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +56 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +48 -0
- package/dist/sdk/workflows/builtin/ralph/claude/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/copilot/index.js +6 -5
- package/dist/sdk/workflows/builtin/ralph/opencode/index.js +6 -5
- package/dist/sdk/workflows/index.d.ts +4 -4
- package/dist/sdk/workflows/index.js +7 -1
- package/package.json +1 -1
- package/src/cli.ts +25 -3
- package/src/commands/cli/chat/index.ts +5 -5
- package/src/commands/cli/init/index.ts +79 -77
- package/src/commands/cli/workflow-command.test.ts +757 -0
- package/src/commands/cli/workflow.test.ts +310 -0
- package/src/commands/cli/workflow.ts +445 -105
- package/src/sdk/components/workflow-picker-panel.tsx +1462 -0
- package/src/sdk/define-workflow.test.ts +101 -0
- package/src/sdk/define-workflow.ts +62 -2
- package/src/sdk/runtime/discovery.ts +111 -8
- package/src/sdk/runtime/executor.ts +89 -32
- package/src/sdk/runtime/tmux.conf +55 -0
- package/src/sdk/runtime/tmux.ts +34 -10
- package/src/sdk/types.ts +67 -4
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +294 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +276 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.ts +38 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +816 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +334 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +284 -0
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +8 -4
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +10 -4
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +8 -4
- package/src/sdk/workflows/index.ts +9 -1
- package/src/services/system/auto-sync.ts +1 -1
- package/src/services/system/install-ui.ts +109 -39
- package/src/theme/colors.ts +65 -1
|
@@ -0,0 +1,1462 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/react */
|
|
2
|
+
/**
|
|
3
|
+
* WorkflowPickerPanel — interactive TUI for `atomic workflow -a <agent>`.
|
|
4
|
+
*
|
|
5
|
+
* Telescope-style fuzzy picker with a two-phase flow:
|
|
6
|
+
*
|
|
7
|
+
* 1. PICK — filter workflows scoped to the current agent, navigate with
|
|
8
|
+
* ↑/↓ or ⌃j/⌃k, press ↵ to lock in a selection.
|
|
9
|
+
* 2. PROMPT — fill the workflow's declared input schema (one field per
|
|
10
|
+
* declared `WorkflowInput`). Free-form workflows fall back to
|
|
11
|
+
* a single `prompt` text field.
|
|
12
|
+
*
|
|
13
|
+
* Pressing ⌃s in the prompt phase validates required fields and opens a
|
|
14
|
+
* CONFIRM modal that shows the fully-composed shell command before
|
|
15
|
+
* submission. y/↵ confirms, n/esc cancels back to the form.
|
|
16
|
+
*
|
|
17
|
+
* Lifecycle:
|
|
18
|
+
*
|
|
19
|
+
* const panel = await WorkflowPickerPanel.create({ agent, workflows });
|
|
20
|
+
* const result = await panel.waitForSelection();
|
|
21
|
+
* panel.destroy();
|
|
22
|
+
* if (result) await executeWorkflow({ ... });
|
|
23
|
+
*
|
|
24
|
+
* `waitForSelection()` resolves with `null` if the user exits without
|
|
25
|
+
* committing (esc in PICK phase, or ⌃c anywhere) and with a
|
|
26
|
+
* `{ workflow, inputs }` record if they confirm the run.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createCliRenderer, type CliRenderer } from "@opentui/core";
|
|
30
|
+
import {
|
|
31
|
+
createRoot,
|
|
32
|
+
useKeyboard,
|
|
33
|
+
type Root,
|
|
34
|
+
} from "@opentui/react";
|
|
35
|
+
import { useState, useEffect, useMemo } from "react";
|
|
36
|
+
import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts";
|
|
37
|
+
import type { AgentType, WorkflowInput } from "../types.ts";
|
|
38
|
+
import type { WorkflowWithMetadata } from "../runtime/discovery.ts";
|
|
39
|
+
import { ErrorBoundary } from "./error-boundary.tsx";
|
|
40
|
+
|
|
41
|
+
// ─── Theme ──────────────────────────────────────
|
|
42
|
+
// The picker uses a slightly extended palette vs. the base terminal theme:
|
|
43
|
+
// an `info` (sky) hue for built-in workflows and a `mauve` hue for global
|
|
44
|
+
// ones — the same distinctions `atomic workflow -l` already draws. The
|
|
45
|
+
// rest is sourced from {@link resolveTheme} so light/dark mode tracks the
|
|
46
|
+
// orchestrator panel.
|
|
47
|
+
export interface PickerTheme {
|
|
48
|
+
background: string;
|
|
49
|
+
backgroundPanel: string;
|
|
50
|
+
backgroundElement: string;
|
|
51
|
+
surface: string;
|
|
52
|
+
text: string;
|
|
53
|
+
textMuted: string;
|
|
54
|
+
textDim: string;
|
|
55
|
+
primary: string;
|
|
56
|
+
success: string;
|
|
57
|
+
error: string;
|
|
58
|
+
warning: string;
|
|
59
|
+
info: string;
|
|
60
|
+
mauve: string;
|
|
61
|
+
border: string;
|
|
62
|
+
borderActive: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildPickerTheme(base: TerminalTheme): PickerTheme {
|
|
66
|
+
// For dark mode the prototype values track Catppuccin Mocha. For light
|
|
67
|
+
// mode we derive muted variants from the base palette — the specific
|
|
68
|
+
// extras (`info`, `mauve`, the three-level background ladder) have no
|
|
69
|
+
// direct entries in `TerminalTheme`, so we pick close-enough Catppuccin
|
|
70
|
+
// values to keep the picker visually consistent with the orchestrator.
|
|
71
|
+
const isDark = base.bg !== "#eff1f5";
|
|
72
|
+
return {
|
|
73
|
+
background: base.bg,
|
|
74
|
+
backgroundPanel: isDark ? "#181825" : "#e6e9ef",
|
|
75
|
+
backgroundElement: isDark ? "#11111b" : "#dce0e8",
|
|
76
|
+
surface: base.surface,
|
|
77
|
+
text: base.text,
|
|
78
|
+
textMuted: isDark ? "#a6adc8" : "#5c5f77",
|
|
79
|
+
textDim: base.dim,
|
|
80
|
+
primary: base.accent,
|
|
81
|
+
success: base.success,
|
|
82
|
+
error: base.error,
|
|
83
|
+
warning: base.warning,
|
|
84
|
+
info: isDark ? "#89dceb" : "#04a5e5",
|
|
85
|
+
mauve: isDark ? "#cba6f7" : "#8839ef",
|
|
86
|
+
border: base.borderDim,
|
|
87
|
+
borderActive: base.border,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Types ──────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
type Source = "local" | "global" | "builtin";
|
|
94
|
+
type Phase = "pick" | "prompt";
|
|
95
|
+
|
|
96
|
+
/** The payload the picker resolves with on successful submission. */
|
|
97
|
+
export interface WorkflowPickerResult {
|
|
98
|
+
/** The workflow the user committed to running. */
|
|
99
|
+
workflow: WorkflowWithMetadata;
|
|
100
|
+
/** Populated form values, one per declared input (or { prompt } for free-form). */
|
|
101
|
+
inputs: Record<string, string>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Fallback field used when a workflow has no structured input schema. */
|
|
105
|
+
const DEFAULT_PROMPT_INPUT: WorkflowInput = {
|
|
106
|
+
name: "prompt",
|
|
107
|
+
type: "text",
|
|
108
|
+
required: true,
|
|
109
|
+
description: "what do you want this workflow to do?",
|
|
110
|
+
placeholder: "describe your task…",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ─── Helpers ────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
const SOURCE_DISPLAY: Record<Source, string> = {
|
|
116
|
+
local: "local",
|
|
117
|
+
global: "global",
|
|
118
|
+
builtin: "builtin",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const SOURCE_DIR: Record<Source, string> = {
|
|
122
|
+
local: ".atomic/workflows",
|
|
123
|
+
global: "~/.atomic/workflows",
|
|
124
|
+
builtin: "built-in",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const SOURCE_COLOR: Record<Source, keyof PickerTheme> = {
|
|
128
|
+
local: "success",
|
|
129
|
+
global: "mauve",
|
|
130
|
+
builtin: "info",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Subsequence fuzzy match — Telescope-style. Returns a score (lower =
|
|
135
|
+
* better) or null for no match. Adjacent matches are rewarded; jumps over
|
|
136
|
+
* non-matching characters are penalized proportionally to the gap.
|
|
137
|
+
*/
|
|
138
|
+
export function fuzzyMatch(query: string, target: string): number | null {
|
|
139
|
+
if (query === "") return 0;
|
|
140
|
+
const q = query.toLowerCase();
|
|
141
|
+
const t = target.toLowerCase();
|
|
142
|
+
let ti = 0;
|
|
143
|
+
let score = 0;
|
|
144
|
+
let prev = -2;
|
|
145
|
+
for (let qi = 0; qi < q.length; qi++) {
|
|
146
|
+
let found = -1;
|
|
147
|
+
while (ti < t.length) {
|
|
148
|
+
if (t[ti] === q[qi]) {
|
|
149
|
+
found = ti;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
ti++;
|
|
153
|
+
}
|
|
154
|
+
if (found === -1) return null;
|
|
155
|
+
score += found === prev + 1 ? 1 : 4 + (found - prev);
|
|
156
|
+
prev = found;
|
|
157
|
+
ti++;
|
|
158
|
+
}
|
|
159
|
+
return score;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── List Building ──────────────────────────────
|
|
163
|
+
|
|
164
|
+
interface ListEntry {
|
|
165
|
+
workflow: WorkflowWithMetadata;
|
|
166
|
+
section: Source;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface ListRow {
|
|
170
|
+
kind: "section" | "entry";
|
|
171
|
+
source?: Source;
|
|
172
|
+
entry?: ListEntry;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function buildEntries(
|
|
176
|
+
query: string,
|
|
177
|
+
workflows: WorkflowWithMetadata[],
|
|
178
|
+
): ListEntry[] {
|
|
179
|
+
type Scored = { wf: WorkflowWithMetadata; score: number };
|
|
180
|
+
const scored: Scored[] = [];
|
|
181
|
+
for (const wf of workflows) {
|
|
182
|
+
const nameScore = fuzzyMatch(query, wf.name);
|
|
183
|
+
const descScore = fuzzyMatch(query, wf.description);
|
|
184
|
+
const best =
|
|
185
|
+
nameScore !== null && descScore !== null
|
|
186
|
+
? Math.min(nameScore, descScore + 2)
|
|
187
|
+
: nameScore !== null
|
|
188
|
+
? nameScore
|
|
189
|
+
: descScore !== null
|
|
190
|
+
? descScore + 2
|
|
191
|
+
: null;
|
|
192
|
+
if (best !== null) scored.push({ wf, score: best });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (query === "") {
|
|
196
|
+
const rest: ListEntry[] = [];
|
|
197
|
+
for (const source of ["local", "global", "builtin"] as Source[]) {
|
|
198
|
+
const group = scored
|
|
199
|
+
.filter((s) => s.wf.source === source)
|
|
200
|
+
.sort((a, b) => a.wf.name.localeCompare(b.wf.name));
|
|
201
|
+
for (const s of group) rest.push({ workflow: s.wf, section: source });
|
|
202
|
+
}
|
|
203
|
+
return rest;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
scored.sort((a, b) => a.score - b.score);
|
|
207
|
+
return scored.map<ListEntry>((s) => ({
|
|
208
|
+
workflow: s.wf,
|
|
209
|
+
section: s.wf.source,
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function buildRows(entries: ListEntry[], query: string): ListRow[] {
|
|
214
|
+
const rows: ListRow[] = [];
|
|
215
|
+
if (query === "") {
|
|
216
|
+
let lastSection: string | null = null;
|
|
217
|
+
for (const e of entries) {
|
|
218
|
+
if (e.section !== lastSection) {
|
|
219
|
+
rows.push({ kind: "section", source: e.section });
|
|
220
|
+
lastSection = e.section;
|
|
221
|
+
}
|
|
222
|
+
rows.push({ kind: "entry", entry: e });
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
for (const e of entries) rows.push({ kind: "entry", entry: e });
|
|
226
|
+
}
|
|
227
|
+
return rows;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Validation ─────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export function isFieldValid(field: WorkflowInput, value: string): boolean {
|
|
233
|
+
if (!field.required) return true;
|
|
234
|
+
if (field.type === "enum") return value !== "";
|
|
235
|
+
return value.trim() !== "";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── Components ─────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function SectionLabel({
|
|
241
|
+
theme,
|
|
242
|
+
label,
|
|
243
|
+
}: {
|
|
244
|
+
theme: PickerTheme;
|
|
245
|
+
label: string;
|
|
246
|
+
}) {
|
|
247
|
+
return (
|
|
248
|
+
<box height={1} flexDirection="row">
|
|
249
|
+
<text>
|
|
250
|
+
<span fg={theme.mauve}>▎ </span>
|
|
251
|
+
<span fg={theme.textMuted}>
|
|
252
|
+
<strong>{label}</strong>
|
|
253
|
+
</span>
|
|
254
|
+
</text>
|
|
255
|
+
</box>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function FilterBar({
|
|
260
|
+
theme,
|
|
261
|
+
query,
|
|
262
|
+
}: {
|
|
263
|
+
theme: PickerTheme;
|
|
264
|
+
query: string;
|
|
265
|
+
}) {
|
|
266
|
+
return (
|
|
267
|
+
<box
|
|
268
|
+
height={3}
|
|
269
|
+
border
|
|
270
|
+
borderStyle="rounded"
|
|
271
|
+
borderColor={theme.borderActive}
|
|
272
|
+
backgroundColor={theme.backgroundPanel}
|
|
273
|
+
flexDirection="row"
|
|
274
|
+
paddingLeft={2}
|
|
275
|
+
paddingRight={2}
|
|
276
|
+
alignItems="center"
|
|
277
|
+
>
|
|
278
|
+
<text>
|
|
279
|
+
<span fg={theme.primary}>
|
|
280
|
+
<strong>❯ </strong>
|
|
281
|
+
</span>
|
|
282
|
+
</text>
|
|
283
|
+
<text>
|
|
284
|
+
<span fg={theme.text}>{query}</span>
|
|
285
|
+
{/* Solid full-cell caret. Rendered as a space with a coloured
|
|
286
|
+
background so the cursor thickness stays stable — the
|
|
287
|
+
previous `▋` half-block halved in width compared to the
|
|
288
|
+
highlighted-char placeholder cursor. */}
|
|
289
|
+
<span bg={theme.primary}> </span>
|
|
290
|
+
</text>
|
|
291
|
+
</box>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function WorkflowList({
|
|
296
|
+
theme,
|
|
297
|
+
rows,
|
|
298
|
+
focusedEntryIdx,
|
|
299
|
+
}: {
|
|
300
|
+
theme: PickerTheme;
|
|
301
|
+
rows: ListRow[];
|
|
302
|
+
focusedEntryIdx: number;
|
|
303
|
+
}) {
|
|
304
|
+
if (rows.length === 0) {
|
|
305
|
+
return (
|
|
306
|
+
<box paddingLeft={2} paddingTop={2}>
|
|
307
|
+
<text>
|
|
308
|
+
<span fg={theme.textDim}>no matches</span>
|
|
309
|
+
</text>
|
|
310
|
+
</box>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let entryCounter = -1;
|
|
315
|
+
return (
|
|
316
|
+
<box flexDirection="column">
|
|
317
|
+
{rows.map((row, i) => {
|
|
318
|
+
if (row.kind === "section") {
|
|
319
|
+
const src = row.source!;
|
|
320
|
+
return (
|
|
321
|
+
<box
|
|
322
|
+
key={`s${i}`}
|
|
323
|
+
height={2}
|
|
324
|
+
paddingTop={1}
|
|
325
|
+
paddingLeft={2}
|
|
326
|
+
>
|
|
327
|
+
<text>
|
|
328
|
+
<span fg={theme[SOURCE_COLOR[src]]}>
|
|
329
|
+
{SOURCE_DISPLAY[src]}
|
|
330
|
+
</span>
|
|
331
|
+
<span fg={theme.textDim}>
|
|
332
|
+
{" (" + SOURCE_DIR[src] + ")"}
|
|
333
|
+
</span>
|
|
334
|
+
</text>
|
|
335
|
+
</box>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
entryCounter++;
|
|
339
|
+
const isFocused = entryCounter === focusedEntryIdx;
|
|
340
|
+
const wf = row.entry!.workflow;
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<box
|
|
344
|
+
key={`e${i}`}
|
|
345
|
+
height={1}
|
|
346
|
+
flexDirection="row"
|
|
347
|
+
backgroundColor={isFocused ? theme.border : "transparent"}
|
|
348
|
+
paddingLeft={1}
|
|
349
|
+
paddingRight={2}
|
|
350
|
+
>
|
|
351
|
+
<text>
|
|
352
|
+
<span fg={isFocused ? theme.primary : theme.textDim}>
|
|
353
|
+
{isFocused ? "▸ " : " "}
|
|
354
|
+
</span>
|
|
355
|
+
<span fg={isFocused ? theme.text : theme.textMuted}>
|
|
356
|
+
{wf.name}
|
|
357
|
+
</span>
|
|
358
|
+
</text>
|
|
359
|
+
</box>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
</box>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function ArgumentRow({
|
|
367
|
+
theme,
|
|
368
|
+
field,
|
|
369
|
+
}: {
|
|
370
|
+
theme: PickerTheme;
|
|
371
|
+
field: WorkflowInput;
|
|
372
|
+
}) {
|
|
373
|
+
const isRequired = field.required ?? false;
|
|
374
|
+
const tagCol = isRequired ? theme.warning : theme.textDim;
|
|
375
|
+
const tagLabel = isRequired ? "required" : "optional";
|
|
376
|
+
const showEnumValues =
|
|
377
|
+
field.type === "enum" && field.values && field.values.length > 0;
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
381
|
+
<box flexDirection="row" height={1}>
|
|
382
|
+
<text>
|
|
383
|
+
<span fg={theme.text}>{field.name}</span>
|
|
384
|
+
</text>
|
|
385
|
+
<box flexGrow={1} />
|
|
386
|
+
<text>
|
|
387
|
+
<span fg={theme.textDim}>{field.type}</span>
|
|
388
|
+
<span fg={theme.textDim}>{" · "}</span>
|
|
389
|
+
<span fg={tagCol}>{tagLabel}</span>
|
|
390
|
+
</text>
|
|
391
|
+
</box>
|
|
392
|
+
|
|
393
|
+
{field.description ? (
|
|
394
|
+
<box height={1}>
|
|
395
|
+
<text>
|
|
396
|
+
<span fg={theme.textMuted}>{field.description}</span>
|
|
397
|
+
</text>
|
|
398
|
+
</box>
|
|
399
|
+
) : null}
|
|
400
|
+
|
|
401
|
+
{showEnumValues ? (
|
|
402
|
+
<box height={1}>
|
|
403
|
+
<text>
|
|
404
|
+
<span fg={theme.textDim}>{field.values!.join(" · ")}</span>
|
|
405
|
+
</text>
|
|
406
|
+
</box>
|
|
407
|
+
) : null}
|
|
408
|
+
|
|
409
|
+
<box height={1} />
|
|
410
|
+
</box>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function Preview({
|
|
415
|
+
theme,
|
|
416
|
+
wf,
|
|
417
|
+
}: {
|
|
418
|
+
theme: PickerTheme;
|
|
419
|
+
wf: WorkflowWithMetadata;
|
|
420
|
+
}) {
|
|
421
|
+
const args: WorkflowInput[] =
|
|
422
|
+
wf.inputs.length > 0 ? [...wf.inputs] : [DEFAULT_PROMPT_INPUT];
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<box
|
|
426
|
+
flexDirection="column"
|
|
427
|
+
paddingLeft={3}
|
|
428
|
+
paddingRight={3}
|
|
429
|
+
paddingTop={1}
|
|
430
|
+
>
|
|
431
|
+
<text>
|
|
432
|
+
<span fg={theme.text}>
|
|
433
|
+
<strong>{wf.name}</strong>
|
|
434
|
+
</span>
|
|
435
|
+
</text>
|
|
436
|
+
|
|
437
|
+
<box height={1} />
|
|
438
|
+
|
|
439
|
+
<text>
|
|
440
|
+
<span fg={theme[SOURCE_COLOR[wf.source as Source]]}>
|
|
441
|
+
{SOURCE_DISPLAY[wf.source as Source]}
|
|
442
|
+
</span>
|
|
443
|
+
<span fg={theme.textDim}>
|
|
444
|
+
{" (" + SOURCE_DIR[wf.source as Source] + ")"}
|
|
445
|
+
</span>
|
|
446
|
+
</text>
|
|
447
|
+
|
|
448
|
+
<box height={2} />
|
|
449
|
+
|
|
450
|
+
<text>
|
|
451
|
+
<span fg={theme.textMuted}>
|
|
452
|
+
{wf.description || "(no description)"}
|
|
453
|
+
</span>
|
|
454
|
+
</text>
|
|
455
|
+
|
|
456
|
+
<box height={2} />
|
|
457
|
+
|
|
458
|
+
<SectionLabel theme={theme} label="ARGUMENTS" />
|
|
459
|
+
<box height={1} />
|
|
460
|
+
{args.map((f) => (
|
|
461
|
+
<ArgumentRow key={f.name} theme={theme} field={f} />
|
|
462
|
+
))}
|
|
463
|
+
</box>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function EmptyPreview({
|
|
468
|
+
theme,
|
|
469
|
+
query,
|
|
470
|
+
}: {
|
|
471
|
+
theme: PickerTheme;
|
|
472
|
+
query: string;
|
|
473
|
+
}) {
|
|
474
|
+
return (
|
|
475
|
+
<box
|
|
476
|
+
flexDirection="column"
|
|
477
|
+
paddingLeft={3}
|
|
478
|
+
paddingRight={3}
|
|
479
|
+
paddingTop={3}
|
|
480
|
+
>
|
|
481
|
+
<text>
|
|
482
|
+
<span fg={theme.textMuted}>No workflows match </span>
|
|
483
|
+
<span fg={theme.text}>"{query}"</span>
|
|
484
|
+
</text>
|
|
485
|
+
<box height={2} />
|
|
486
|
+
<text>
|
|
487
|
+
<span fg={theme.textDim}>
|
|
488
|
+
Press backspace to widen your search, or
|
|
489
|
+
</span>
|
|
490
|
+
</text>
|
|
491
|
+
<box height={2} />
|
|
492
|
+
<text>
|
|
493
|
+
<span fg={theme.textDim}>create a new one at</span>
|
|
494
|
+
</text>
|
|
495
|
+
<box height={1} />
|
|
496
|
+
<box paddingLeft={2}>
|
|
497
|
+
<text>
|
|
498
|
+
<span fg={theme.primary}>
|
|
499
|
+
.atomic/workflows/<name>/<agent>/index.ts
|
|
500
|
+
</span>
|
|
501
|
+
</text>
|
|
502
|
+
</box>
|
|
503
|
+
</box>
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ─── Field renderers ────────────────────────────
|
|
508
|
+
|
|
509
|
+
const TEXT_FIELD_LINES = 3;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Render a placeholder with a solid full-cell caret overlapping its
|
|
513
|
+
* first character. The caret is a full cell wide — same thickness as
|
|
514
|
+
* the trailing-caret cell used for typed text — so switching between
|
|
515
|
+
* "empty" and "has input" states never visually halves the cursor.
|
|
516
|
+
*
|
|
517
|
+
* When the field is not focused, the placeholder renders plain dim
|
|
518
|
+
* text without the caret highlight.
|
|
519
|
+
*/
|
|
520
|
+
function PlaceholderWithCursor({
|
|
521
|
+
theme,
|
|
522
|
+
placeholder,
|
|
523
|
+
focused,
|
|
524
|
+
}: {
|
|
525
|
+
theme: PickerTheme;
|
|
526
|
+
placeholder: string;
|
|
527
|
+
focused: boolean;
|
|
528
|
+
}) {
|
|
529
|
+
const effective = placeholder.length > 0 ? placeholder : " ";
|
|
530
|
+
const first = effective.slice(0, 1);
|
|
531
|
+
const rest = effective.slice(1);
|
|
532
|
+
|
|
533
|
+
if (!focused) {
|
|
534
|
+
return (
|
|
535
|
+
<text>
|
|
536
|
+
<span fg={theme.textDim}>{effective}</span>
|
|
537
|
+
</text>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<text>
|
|
543
|
+
<span fg={theme.surface} bg={theme.primary}>
|
|
544
|
+
{first}
|
|
545
|
+
</span>
|
|
546
|
+
<span fg={theme.textDim}>{rest}</span>
|
|
547
|
+
</text>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function TextAreaContent({
|
|
552
|
+
theme,
|
|
553
|
+
value,
|
|
554
|
+
placeholder,
|
|
555
|
+
focused,
|
|
556
|
+
lines,
|
|
557
|
+
}: {
|
|
558
|
+
theme: PickerTheme;
|
|
559
|
+
value: string;
|
|
560
|
+
placeholder: string;
|
|
561
|
+
focused: boolean;
|
|
562
|
+
lines: number;
|
|
563
|
+
}) {
|
|
564
|
+
const textLines = value.split("\n");
|
|
565
|
+
const start = Math.max(0, textLines.length - lines);
|
|
566
|
+
const visible: string[] = [];
|
|
567
|
+
for (let i = 0; i < lines; i++) {
|
|
568
|
+
visible.push(textLines[start + i] ?? "");
|
|
569
|
+
}
|
|
570
|
+
const cursorLine = Math.min(lines - 1, textLines.length - 1 - start);
|
|
571
|
+
const isEmpty = value === "";
|
|
572
|
+
|
|
573
|
+
return (
|
|
574
|
+
<box flexDirection="column">
|
|
575
|
+
{visible.map((line, i) => {
|
|
576
|
+
if (isEmpty && i === 0) {
|
|
577
|
+
return (
|
|
578
|
+
<box key={i} height={1}>
|
|
579
|
+
<PlaceholderWithCursor
|
|
580
|
+
theme={theme}
|
|
581
|
+
placeholder={placeholder}
|
|
582
|
+
focused={focused}
|
|
583
|
+
/>
|
|
584
|
+
</box>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
// Trailing caret on the active line. Rendered as a
|
|
588
|
+
// background-coloured space so the cell width matches the
|
|
589
|
+
// placeholder-overlap caret exactly — no thickness change
|
|
590
|
+
// between empty and typed states.
|
|
591
|
+
const showCursorHere = focused && !isEmpty && i === cursorLine;
|
|
592
|
+
return (
|
|
593
|
+
<box key={i} height={1}>
|
|
594
|
+
<text>
|
|
595
|
+
<span fg={theme.text}>{line}</span>
|
|
596
|
+
{showCursorHere ? <span bg={theme.primary}> </span> : null}
|
|
597
|
+
</text>
|
|
598
|
+
</box>
|
|
599
|
+
);
|
|
600
|
+
})}
|
|
601
|
+
</box>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function StringContent({
|
|
606
|
+
theme,
|
|
607
|
+
value,
|
|
608
|
+
placeholder,
|
|
609
|
+
focused,
|
|
610
|
+
}: {
|
|
611
|
+
theme: PickerTheme;
|
|
612
|
+
value: string;
|
|
613
|
+
placeholder: string;
|
|
614
|
+
focused: boolean;
|
|
615
|
+
}) {
|
|
616
|
+
const isEmpty = value === "";
|
|
617
|
+
|
|
618
|
+
if (isEmpty) {
|
|
619
|
+
return (
|
|
620
|
+
<box height={1} flexDirection="row">
|
|
621
|
+
<PlaceholderWithCursor
|
|
622
|
+
theme={theme}
|
|
623
|
+
placeholder={placeholder}
|
|
624
|
+
focused={focused}
|
|
625
|
+
/>
|
|
626
|
+
</box>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<box height={1} flexDirection="row">
|
|
632
|
+
<text>
|
|
633
|
+
<span fg={theme.text}>{value}</span>
|
|
634
|
+
{focused ? <span bg={theme.primary}> </span> : null}
|
|
635
|
+
</text>
|
|
636
|
+
</box>
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function EnumContent({
|
|
641
|
+
theme,
|
|
642
|
+
values,
|
|
643
|
+
selected,
|
|
644
|
+
focused,
|
|
645
|
+
}: {
|
|
646
|
+
theme: PickerTheme;
|
|
647
|
+
values: string[];
|
|
648
|
+
selected: string;
|
|
649
|
+
focused: boolean;
|
|
650
|
+
}) {
|
|
651
|
+
return (
|
|
652
|
+
<box height={1} flexDirection="row">
|
|
653
|
+
{values.map((v, i) => {
|
|
654
|
+
const isSelected = v === selected;
|
|
655
|
+
const marker = isSelected ? "●" : "○";
|
|
656
|
+
const markerColor = isSelected
|
|
657
|
+
? focused
|
|
658
|
+
? theme.primary
|
|
659
|
+
: theme.success
|
|
660
|
+
: theme.textDim;
|
|
661
|
+
const textColor = isSelected
|
|
662
|
+
? focused
|
|
663
|
+
? theme.text
|
|
664
|
+
: theme.textMuted
|
|
665
|
+
: theme.textDim;
|
|
666
|
+
return (
|
|
667
|
+
<box
|
|
668
|
+
key={v}
|
|
669
|
+
flexDirection="row"
|
|
670
|
+
paddingLeft={i > 0 ? 3 : 0}
|
|
671
|
+
height={1}
|
|
672
|
+
>
|
|
673
|
+
<text>
|
|
674
|
+
<span fg={markerColor}>{marker} </span>
|
|
675
|
+
<span fg={textColor}>{v}</span>
|
|
676
|
+
</text>
|
|
677
|
+
</box>
|
|
678
|
+
);
|
|
679
|
+
})}
|
|
680
|
+
</box>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function Field({
|
|
685
|
+
theme,
|
|
686
|
+
field,
|
|
687
|
+
value,
|
|
688
|
+
focused,
|
|
689
|
+
}: {
|
|
690
|
+
theme: PickerTheme;
|
|
691
|
+
field: WorkflowInput;
|
|
692
|
+
value: string;
|
|
693
|
+
focused: boolean;
|
|
694
|
+
}) {
|
|
695
|
+
const borderCol = focused ? theme.primary : theme.border;
|
|
696
|
+
const bgCol = focused ? theme.backgroundPanel : theme.backgroundElement;
|
|
697
|
+
|
|
698
|
+
const boxHeight = field.type === "text" ? TEXT_FIELD_LINES + 2 : 3;
|
|
699
|
+
|
|
700
|
+
const tagCol = field.required ? theme.warning : theme.textDim;
|
|
701
|
+
const tagLabel = field.required ? "required" : "optional";
|
|
702
|
+
const captionDesc = field.description ? " · " + field.description : "";
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<box flexDirection="column">
|
|
706
|
+
<box
|
|
707
|
+
border
|
|
708
|
+
borderStyle="rounded"
|
|
709
|
+
borderColor={borderCol}
|
|
710
|
+
backgroundColor={bgCol}
|
|
711
|
+
flexDirection="column"
|
|
712
|
+
paddingLeft={2}
|
|
713
|
+
paddingRight={2}
|
|
714
|
+
height={boxHeight}
|
|
715
|
+
justifyContent={field.type === "text" ? "flex-start" : "center"}
|
|
716
|
+
title={` ${field.name} `}
|
|
717
|
+
titleAlignment="left"
|
|
718
|
+
>
|
|
719
|
+
{field.type === "text" ? (
|
|
720
|
+
<TextAreaContent
|
|
721
|
+
theme={theme}
|
|
722
|
+
value={value}
|
|
723
|
+
placeholder={field.placeholder ?? ""}
|
|
724
|
+
focused={focused}
|
|
725
|
+
lines={TEXT_FIELD_LINES}
|
|
726
|
+
/>
|
|
727
|
+
) : field.type === "string" ? (
|
|
728
|
+
<StringContent
|
|
729
|
+
theme={theme}
|
|
730
|
+
value={value}
|
|
731
|
+
placeholder={field.placeholder ?? ""}
|
|
732
|
+
focused={focused}
|
|
733
|
+
/>
|
|
734
|
+
) : field.type === "enum" ? (
|
|
735
|
+
<EnumContent
|
|
736
|
+
theme={theme}
|
|
737
|
+
values={field.values ?? []}
|
|
738
|
+
selected={value}
|
|
739
|
+
focused={focused}
|
|
740
|
+
/>
|
|
741
|
+
) : null}
|
|
742
|
+
</box>
|
|
743
|
+
|
|
744
|
+
<box paddingLeft={2} paddingRight={2} height={1}>
|
|
745
|
+
<text>
|
|
746
|
+
<span fg={theme.textDim}>{field.type}</span>
|
|
747
|
+
<span fg={theme.textDim}>{" · "}</span>
|
|
748
|
+
<span fg={tagCol}>{tagLabel}</span>
|
|
749
|
+
<span fg={theme.textDim}>{captionDesc}</span>
|
|
750
|
+
</text>
|
|
751
|
+
</box>
|
|
752
|
+
|
|
753
|
+
<box height={1} />
|
|
754
|
+
</box>
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function InputPhase({
|
|
759
|
+
theme,
|
|
760
|
+
workflow,
|
|
761
|
+
agent,
|
|
762
|
+
fields,
|
|
763
|
+
values,
|
|
764
|
+
focusedFieldIdx,
|
|
765
|
+
}: {
|
|
766
|
+
theme: PickerTheme;
|
|
767
|
+
workflow: WorkflowWithMetadata;
|
|
768
|
+
agent: AgentType;
|
|
769
|
+
fields: WorkflowInput[];
|
|
770
|
+
values: Record<string, string>;
|
|
771
|
+
focusedFieldIdx: number;
|
|
772
|
+
}) {
|
|
773
|
+
const isStructured = workflow.inputs.length > 0;
|
|
774
|
+
|
|
775
|
+
return (
|
|
776
|
+
<box
|
|
777
|
+
flexDirection="column"
|
|
778
|
+
paddingLeft={3}
|
|
779
|
+
paddingRight={3}
|
|
780
|
+
paddingTop={2}
|
|
781
|
+
flexGrow={1}
|
|
782
|
+
>
|
|
783
|
+
<box
|
|
784
|
+
border
|
|
785
|
+
borderStyle="rounded"
|
|
786
|
+
borderColor={theme.border}
|
|
787
|
+
backgroundColor={theme.backgroundPanel}
|
|
788
|
+
flexDirection="column"
|
|
789
|
+
paddingLeft={2}
|
|
790
|
+
paddingRight={2}
|
|
791
|
+
paddingTop={1}
|
|
792
|
+
paddingBottom={1}
|
|
793
|
+
>
|
|
794
|
+
<text>
|
|
795
|
+
<span fg={theme.primary}>
|
|
796
|
+
<strong>▸ </strong>
|
|
797
|
+
</span>
|
|
798
|
+
<span fg={theme.text}>
|
|
799
|
+
<strong>{workflow.name}</strong>
|
|
800
|
+
</span>
|
|
801
|
+
<span fg={theme.textDim}>{" · "}</span>
|
|
802
|
+
<span fg={theme.mauve}>{agent}</span>
|
|
803
|
+
<span fg={theme.textDim}>{" · "}</span>
|
|
804
|
+
<span fg={theme[SOURCE_COLOR[workflow.source as Source]]}>
|
|
805
|
+
{SOURCE_DISPLAY[workflow.source as Source]}
|
|
806
|
+
</span>
|
|
807
|
+
<span fg={theme.textDim}>
|
|
808
|
+
{" (" + SOURCE_DIR[workflow.source as Source] + ")"}
|
|
809
|
+
</span>
|
|
810
|
+
</text>
|
|
811
|
+
<box height={1} />
|
|
812
|
+
<text>
|
|
813
|
+
<span fg={theme.textMuted}>
|
|
814
|
+
{workflow.description || "(no description)"}
|
|
815
|
+
</span>
|
|
816
|
+
</text>
|
|
817
|
+
</box>
|
|
818
|
+
|
|
819
|
+
<box height={2} />
|
|
820
|
+
|
|
821
|
+
<box flexDirection="row" height={1}>
|
|
822
|
+
<text>
|
|
823
|
+
<span fg={theme.textDim}>
|
|
824
|
+
<strong>{isStructured ? "INPUTS" : "PROMPT"}</strong>
|
|
825
|
+
</span>
|
|
826
|
+
</text>
|
|
827
|
+
<box flexGrow={1} />
|
|
828
|
+
<text>
|
|
829
|
+
<span fg={theme.textDim}>
|
|
830
|
+
{isStructured ? `${focusedFieldIdx + 1} / ${fields.length}` : ""}
|
|
831
|
+
</span>
|
|
832
|
+
</text>
|
|
833
|
+
</box>
|
|
834
|
+
<box height={1} />
|
|
835
|
+
|
|
836
|
+
{fields.map((f, i) => (
|
|
837
|
+
<Field
|
|
838
|
+
key={f.name}
|
|
839
|
+
theme={theme}
|
|
840
|
+
field={f}
|
|
841
|
+
value={values[f.name] ?? ""}
|
|
842
|
+
focused={i === focusedFieldIdx}
|
|
843
|
+
/>
|
|
844
|
+
))}
|
|
845
|
+
</box>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function ConfirmModal({
|
|
850
|
+
theme,
|
|
851
|
+
workflow,
|
|
852
|
+
agent,
|
|
853
|
+
}: {
|
|
854
|
+
theme: PickerTheme;
|
|
855
|
+
workflow: WorkflowWithMetadata;
|
|
856
|
+
agent: AgentType;
|
|
857
|
+
}) {
|
|
858
|
+
return (
|
|
859
|
+
<box
|
|
860
|
+
position="absolute"
|
|
861
|
+
left={0}
|
|
862
|
+
top={0}
|
|
863
|
+
width="100%"
|
|
864
|
+
height="100%"
|
|
865
|
+
justifyContent="center"
|
|
866
|
+
alignItems="center"
|
|
867
|
+
zIndex={100}
|
|
868
|
+
>
|
|
869
|
+
<box
|
|
870
|
+
border
|
|
871
|
+
borderStyle="rounded"
|
|
872
|
+
borderColor={theme.success}
|
|
873
|
+
backgroundColor={theme.backgroundPanel}
|
|
874
|
+
flexDirection="column"
|
|
875
|
+
paddingLeft={3}
|
|
876
|
+
paddingRight={3}
|
|
877
|
+
paddingTop={1}
|
|
878
|
+
paddingBottom={1}
|
|
879
|
+
title=" ready to run "
|
|
880
|
+
titleAlignment="center"
|
|
881
|
+
>
|
|
882
|
+
{/* Header — the form the user just filled in already shows the
|
|
883
|
+
workflow name, agent, and field values, so the modal stays
|
|
884
|
+
minimal and focuses on the submit/cancel question rather
|
|
885
|
+
than restating the full invocation. */}
|
|
886
|
+
<text>
|
|
887
|
+
<span fg={theme.success}>
|
|
888
|
+
<strong>✓ </strong>
|
|
889
|
+
</span>
|
|
890
|
+
<span fg={theme.text}>
|
|
891
|
+
<strong>{workflow.name}</strong>
|
|
892
|
+
</span>
|
|
893
|
+
<span fg={theme.textDim}>{" · "}</span>
|
|
894
|
+
<span fg={theme.mauve}>{agent}</span>
|
|
895
|
+
</text>
|
|
896
|
+
|
|
897
|
+
<box height={1} />
|
|
898
|
+
|
|
899
|
+
<text>
|
|
900
|
+
<span fg={theme.textDim}>
|
|
901
|
+
submit and run this workflow?{" "}
|
|
902
|
+
</span>
|
|
903
|
+
<span fg={theme.success}>
|
|
904
|
+
<strong>y</strong>
|
|
905
|
+
</span>
|
|
906
|
+
<span fg={theme.textDim}> submit · </span>
|
|
907
|
+
<span fg={theme.error}>
|
|
908
|
+
<strong>n</strong>
|
|
909
|
+
</span>
|
|
910
|
+
<span fg={theme.textDim}> cancel</span>
|
|
911
|
+
</text>
|
|
912
|
+
</box>
|
|
913
|
+
</box>
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Per-agent brand color used as the Header pill background.
|
|
918
|
+
const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
|
|
919
|
+
claude: "warning",
|
|
920
|
+
copilot: "success",
|
|
921
|
+
opencode: "mauve",
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
function Header({
|
|
925
|
+
theme,
|
|
926
|
+
phase,
|
|
927
|
+
confirmOpen,
|
|
928
|
+
selectedAgent,
|
|
929
|
+
scopedCount,
|
|
930
|
+
}: {
|
|
931
|
+
theme: PickerTheme;
|
|
932
|
+
phase: Phase;
|
|
933
|
+
confirmOpen: boolean;
|
|
934
|
+
selectedAgent: AgentType;
|
|
935
|
+
scopedCount: number;
|
|
936
|
+
}) {
|
|
937
|
+
const phaseLabel = confirmOpen
|
|
938
|
+
? "confirm"
|
|
939
|
+
: phase === "pick"
|
|
940
|
+
? "select"
|
|
941
|
+
: "compose";
|
|
942
|
+
const pillBg = theme[AGENT_PILL_COLOR[selectedAgent]];
|
|
943
|
+
|
|
944
|
+
return (
|
|
945
|
+
<box
|
|
946
|
+
height={1}
|
|
947
|
+
backgroundColor={theme.surface}
|
|
948
|
+
flexDirection="row"
|
|
949
|
+
paddingRight={2}
|
|
950
|
+
alignItems="center"
|
|
951
|
+
>
|
|
952
|
+
<text>
|
|
953
|
+
<span fg={theme.surface} bg={pillBg}>
|
|
954
|
+
<strong>{" " + selectedAgent.toUpperCase() + " "}</strong>
|
|
955
|
+
</span>
|
|
956
|
+
</text>
|
|
957
|
+
<text>
|
|
958
|
+
<span fg={theme.textDim}>{" workflow "}</span>
|
|
959
|
+
</text>
|
|
960
|
+
<text>
|
|
961
|
+
<span fg={theme.textMuted}>›</span>
|
|
962
|
+
</text>
|
|
963
|
+
<text>
|
|
964
|
+
<span fg={theme.textDim}>{" " + phaseLabel}</span>
|
|
965
|
+
</text>
|
|
966
|
+
<box flexGrow={1} />
|
|
967
|
+
<text>
|
|
968
|
+
<span fg={theme.textDim}>
|
|
969
|
+
{scopedCount + (scopedCount === 1 ? " workflow" : " workflows")}
|
|
970
|
+
</span>
|
|
971
|
+
</text>
|
|
972
|
+
</box>
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function Statusline({
|
|
977
|
+
theme,
|
|
978
|
+
phase,
|
|
979
|
+
confirmOpen,
|
|
980
|
+
hints,
|
|
981
|
+
focusedWf,
|
|
982
|
+
}: {
|
|
983
|
+
theme: PickerTheme;
|
|
984
|
+
phase: Phase;
|
|
985
|
+
confirmOpen: boolean;
|
|
986
|
+
hints: { key: string; label: string; dim?: boolean }[];
|
|
987
|
+
focusedWf: WorkflowWithMetadata | undefined;
|
|
988
|
+
}) {
|
|
989
|
+
const modeLabel = confirmOpen
|
|
990
|
+
? "CONFIRM"
|
|
991
|
+
: phase === "pick"
|
|
992
|
+
? "PICK"
|
|
993
|
+
: "PROMPT";
|
|
994
|
+
const modeColor = confirmOpen
|
|
995
|
+
? theme.mauve
|
|
996
|
+
: phase === "pick"
|
|
997
|
+
? theme.primary
|
|
998
|
+
: theme.success;
|
|
999
|
+
|
|
1000
|
+
return (
|
|
1001
|
+
<box height={1} flexDirection="row" backgroundColor={theme.surface}>
|
|
1002
|
+
<box
|
|
1003
|
+
backgroundColor={modeColor}
|
|
1004
|
+
paddingLeft={1}
|
|
1005
|
+
paddingRight={1}
|
|
1006
|
+
alignItems="center"
|
|
1007
|
+
>
|
|
1008
|
+
<text fg={theme.surface}>
|
|
1009
|
+
<strong>{modeLabel}</strong>
|
|
1010
|
+
</text>
|
|
1011
|
+
</box>
|
|
1012
|
+
|
|
1013
|
+
{focusedWf ? (
|
|
1014
|
+
<box paddingLeft={1} paddingRight={1} alignItems="center">
|
|
1015
|
+
<text>
|
|
1016
|
+
<span fg={theme.text}>{focusedWf.name}</span>
|
|
1017
|
+
</text>
|
|
1018
|
+
</box>
|
|
1019
|
+
) : null}
|
|
1020
|
+
|
|
1021
|
+
<box flexGrow={1} />
|
|
1022
|
+
|
|
1023
|
+
<box paddingRight={2} alignItems="center" flexDirection="row">
|
|
1024
|
+
{hints.map((h, i) => (
|
|
1025
|
+
<box key={i} flexDirection="row">
|
|
1026
|
+
{i > 0 ? (
|
|
1027
|
+
<text>
|
|
1028
|
+
<span fg={theme.textDim}>{" · "}</span>
|
|
1029
|
+
</text>
|
|
1030
|
+
) : null}
|
|
1031
|
+
<text>
|
|
1032
|
+
<span fg={h.dim ? theme.textDim : theme.text}>{h.key}</span>
|
|
1033
|
+
<span fg={h.dim ? theme.textDim : theme.textMuted}>
|
|
1034
|
+
{" " + h.label}
|
|
1035
|
+
</span>
|
|
1036
|
+
</text>
|
|
1037
|
+
</box>
|
|
1038
|
+
))}
|
|
1039
|
+
</box>
|
|
1040
|
+
</box>
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ─── App ────────────────────────────────────────
|
|
1045
|
+
|
|
1046
|
+
interface PickerAppProps {
|
|
1047
|
+
theme: PickerTheme;
|
|
1048
|
+
agent: AgentType;
|
|
1049
|
+
workflows: WorkflowWithMetadata[];
|
|
1050
|
+
onSubmit: (result: WorkflowPickerResult) => void;
|
|
1051
|
+
onCancel: () => void;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export function WorkflowPicker({
|
|
1055
|
+
theme,
|
|
1056
|
+
agent,
|
|
1057
|
+
workflows,
|
|
1058
|
+
onSubmit,
|
|
1059
|
+
onCancel,
|
|
1060
|
+
}: PickerAppProps) {
|
|
1061
|
+
const [phase, setPhase] = useState<Phase>("pick");
|
|
1062
|
+
const [query, setQuery] = useState("");
|
|
1063
|
+
const [entryIdx, setEntryIdx] = useState(0);
|
|
1064
|
+
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
|
1065
|
+
const [focusedFieldIdx, setFocusedFieldIdx] = useState(0);
|
|
1066
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
1067
|
+
|
|
1068
|
+
// Note: the cursor is rendered as a steady full-cell block rather
|
|
1069
|
+
// than a blinking caret. Blinking was causing the caret cell to
|
|
1070
|
+
// flash visibly every 530ms, which read as a "text block flashing"
|
|
1071
|
+
// bug on top of the already-jarring thickness change that used to
|
|
1072
|
+
// happen when switching between placeholder and typed-text cursors.
|
|
1073
|
+
// Both issues go away with a stable caret.
|
|
1074
|
+
|
|
1075
|
+
const entries = useMemo(() => buildEntries(query, workflows), [query, workflows]);
|
|
1076
|
+
const rows = useMemo(() => buildRows(entries, query), [entries, query]);
|
|
1077
|
+
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
if (entryIdx >= entries.length) {
|
|
1080
|
+
setEntryIdx(Math.max(0, entries.length - 1));
|
|
1081
|
+
}
|
|
1082
|
+
}, [entries.length, entryIdx]);
|
|
1083
|
+
|
|
1084
|
+
const focusedWf = entries[entryIdx]?.workflow;
|
|
1085
|
+
|
|
1086
|
+
const currentFields: WorkflowInput[] =
|
|
1087
|
+
focusedWf && focusedWf.inputs.length > 0
|
|
1088
|
+
? [...focusedWf.inputs]
|
|
1089
|
+
: [DEFAULT_PROMPT_INPUT];
|
|
1090
|
+
const currentField = currentFields[focusedFieldIdx];
|
|
1091
|
+
|
|
1092
|
+
const invalidFieldIndices = useMemo(() => {
|
|
1093
|
+
const out: number[] = [];
|
|
1094
|
+
for (let i = 0; i < currentFields.length; i++) {
|
|
1095
|
+
const f = currentFields[i]!;
|
|
1096
|
+
const v = fieldValues[f.name] ?? "";
|
|
1097
|
+
if (!isFieldValid(f, v)) out.push(i);
|
|
1098
|
+
}
|
|
1099
|
+
return out;
|
|
1100
|
+
}, [currentFields, fieldValues]);
|
|
1101
|
+
const isFormValid = invalidFieldIndices.length === 0;
|
|
1102
|
+
|
|
1103
|
+
useKeyboard((key) => {
|
|
1104
|
+
if (key.ctrl && key.name === "c") {
|
|
1105
|
+
onCancel();
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (confirmOpen) {
|
|
1110
|
+
if (key.name === "y" || key.name === "return") {
|
|
1111
|
+
if (!focusedWf) return;
|
|
1112
|
+
onSubmit({ workflow: focusedWf, inputs: { ...fieldValues } });
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (key.name === "n" || key.name === "escape") {
|
|
1116
|
+
setConfirmOpen(false);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (phase === "pick") {
|
|
1123
|
+
if (key.name === "escape") {
|
|
1124
|
+
onCancel();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (key.name === "up" || (key.ctrl && key.name === "k")) {
|
|
1128
|
+
setEntryIdx((i: number) => Math.max(0, i - 1));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (key.name === "down" || (key.ctrl && key.name === "j")) {
|
|
1132
|
+
setEntryIdx((i: number) =>
|
|
1133
|
+
Math.min(entries.length - 1, i + 1),
|
|
1134
|
+
);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (key.name === "return") {
|
|
1138
|
+
if (focusedWf) {
|
|
1139
|
+
const inputs: WorkflowInput[] =
|
|
1140
|
+
focusedWf.inputs.length > 0
|
|
1141
|
+
? [...focusedWf.inputs]
|
|
1142
|
+
: [DEFAULT_PROMPT_INPUT];
|
|
1143
|
+
const initial: Record<string, string> = {};
|
|
1144
|
+
for (const f of inputs) {
|
|
1145
|
+
initial[f.name] =
|
|
1146
|
+
f.default ??
|
|
1147
|
+
(f.type === "enum" ? (f.values?.[0] ?? "") : "");
|
|
1148
|
+
}
|
|
1149
|
+
setFieldValues(initial);
|
|
1150
|
+
setFocusedFieldIdx(0);
|
|
1151
|
+
setPhase("prompt");
|
|
1152
|
+
}
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
if (key.name === "backspace") {
|
|
1156
|
+
setQuery((q: string) => q.slice(0, -1));
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (
|
|
1160
|
+
key.sequence &&
|
|
1161
|
+
key.sequence.length === 1 &&
|
|
1162
|
+
!key.ctrl &&
|
|
1163
|
+
!key.meta
|
|
1164
|
+
) {
|
|
1165
|
+
const c = key.sequence;
|
|
1166
|
+
if (c >= " " && c <= "~") setQuery((q: string) => q + c);
|
|
1167
|
+
}
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// ── PROMPT phase ──
|
|
1172
|
+
if (key.name === "escape") {
|
|
1173
|
+
setPhase("pick");
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (key.ctrl && key.name === "s") {
|
|
1177
|
+
if (!isFormValid) {
|
|
1178
|
+
setFocusedFieldIdx(invalidFieldIndices[0]!);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
setConfirmOpen(true);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (key.name === "tab") {
|
|
1185
|
+
setFocusedFieldIdx((i: number) => {
|
|
1186
|
+
const len = currentFields.length;
|
|
1187
|
+
if (len <= 1) return 0;
|
|
1188
|
+
return key.shift ? (i - 1 + len) % len : (i + 1) % len;
|
|
1189
|
+
});
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (!currentField) return;
|
|
1193
|
+
|
|
1194
|
+
if (currentField.type === "enum") {
|
|
1195
|
+
const values = currentField.values ?? [];
|
|
1196
|
+
if (values.length === 0) return;
|
|
1197
|
+
if (key.name === "left" || key.name === "right") {
|
|
1198
|
+
setFieldValues((prev: Record<string, string>) => {
|
|
1199
|
+
const cur = prev[currentField.name] ?? values[0] ?? "";
|
|
1200
|
+
const idx = Math.max(0, values.indexOf(cur));
|
|
1201
|
+
const delta = key.name === "left" ? -1 : 1;
|
|
1202
|
+
const nextIdx = (idx + delta + values.length) % values.length;
|
|
1203
|
+
return { ...prev, [currentField.name]: values[nextIdx] ?? "" };
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (key.name === "return") {
|
|
1210
|
+
if (currentField.type === "text") {
|
|
1211
|
+
setFieldValues((prev: Record<string, string>) => ({
|
|
1212
|
+
...prev,
|
|
1213
|
+
[currentField.name]: (prev[currentField.name] ?? "") + "\n",
|
|
1214
|
+
}));
|
|
1215
|
+
} else {
|
|
1216
|
+
setFocusedFieldIdx((i: number) =>
|
|
1217
|
+
Math.min(currentFields.length - 1, i + 1),
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
if (key.name === "backspace") {
|
|
1223
|
+
setFieldValues((prev: Record<string, string>) => ({
|
|
1224
|
+
...prev,
|
|
1225
|
+
[currentField.name]: (prev[currentField.name] ?? "").slice(0, -1),
|
|
1226
|
+
}));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (
|
|
1230
|
+
key.sequence &&
|
|
1231
|
+
key.sequence.length === 1 &&
|
|
1232
|
+
!key.ctrl &&
|
|
1233
|
+
!key.meta
|
|
1234
|
+
) {
|
|
1235
|
+
const c = key.sequence;
|
|
1236
|
+
if (c >= " " && c <= "~") {
|
|
1237
|
+
setFieldValues((prev: Record<string, string>) => ({
|
|
1238
|
+
...prev,
|
|
1239
|
+
[currentField.name]: (prev[currentField.name] ?? "") + c,
|
|
1240
|
+
}));
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
const pickHints = [
|
|
1246
|
+
{ key: "↑↓", label: "navigate" },
|
|
1247
|
+
{ key: "↵", label: "select" },
|
|
1248
|
+
{ key: "esc", label: "quit" },
|
|
1249
|
+
];
|
|
1250
|
+
const promptHints = [
|
|
1251
|
+
{ key: "tab", label: "to navigate forward" },
|
|
1252
|
+
{ key: "shift+tab", label: "to navigate backward" },
|
|
1253
|
+
{ key: "ctrl+s", label: "to run", dim: !isFormValid },
|
|
1254
|
+
];
|
|
1255
|
+
const confirmHints = [
|
|
1256
|
+
{ key: "y", label: "submit" },
|
|
1257
|
+
{ key: "n", label: "cancel" },
|
|
1258
|
+
];
|
|
1259
|
+
|
|
1260
|
+
const hints = confirmOpen
|
|
1261
|
+
? confirmHints
|
|
1262
|
+
: phase === "pick"
|
|
1263
|
+
? pickHints
|
|
1264
|
+
: promptHints;
|
|
1265
|
+
|
|
1266
|
+
return (
|
|
1267
|
+
<box
|
|
1268
|
+
position="relative"
|
|
1269
|
+
width="100%"
|
|
1270
|
+
height="100%"
|
|
1271
|
+
flexDirection="column"
|
|
1272
|
+
backgroundColor={theme.background}
|
|
1273
|
+
>
|
|
1274
|
+
<Header
|
|
1275
|
+
theme={theme}
|
|
1276
|
+
phase={phase}
|
|
1277
|
+
confirmOpen={confirmOpen}
|
|
1278
|
+
selectedAgent={agent}
|
|
1279
|
+
scopedCount={workflows.length}
|
|
1280
|
+
/>
|
|
1281
|
+
|
|
1282
|
+
{phase === "pick" ? (
|
|
1283
|
+
<box
|
|
1284
|
+
flexGrow={1}
|
|
1285
|
+
flexDirection="row"
|
|
1286
|
+
paddingLeft={2}
|
|
1287
|
+
paddingRight={2}
|
|
1288
|
+
paddingTop={1}
|
|
1289
|
+
>
|
|
1290
|
+
<box width={36} flexDirection="column">
|
|
1291
|
+
<FilterBar theme={theme} query={query} />
|
|
1292
|
+
<box height={1} />
|
|
1293
|
+
<WorkflowList
|
|
1294
|
+
theme={theme}
|
|
1295
|
+
rows={rows}
|
|
1296
|
+
focusedEntryIdx={entryIdx}
|
|
1297
|
+
/>
|
|
1298
|
+
</box>
|
|
1299
|
+
<box width={1} backgroundColor={theme.border} />
|
|
1300
|
+
<box flexGrow={1} flexDirection="column">
|
|
1301
|
+
{focusedWf ? (
|
|
1302
|
+
<Preview theme={theme} wf={focusedWf} />
|
|
1303
|
+
) : (
|
|
1304
|
+
<EmptyPreview theme={theme} query={query} />
|
|
1305
|
+
)}
|
|
1306
|
+
</box>
|
|
1307
|
+
</box>
|
|
1308
|
+
) : phase === "prompt" && focusedWf ? (
|
|
1309
|
+
<InputPhase
|
|
1310
|
+
theme={theme}
|
|
1311
|
+
workflow={focusedWf}
|
|
1312
|
+
agent={agent}
|
|
1313
|
+
fields={currentFields}
|
|
1314
|
+
values={fieldValues}
|
|
1315
|
+
focusedFieldIdx={focusedFieldIdx}
|
|
1316
|
+
/>
|
|
1317
|
+
) : null}
|
|
1318
|
+
|
|
1319
|
+
<Statusline
|
|
1320
|
+
theme={theme}
|
|
1321
|
+
phase={phase}
|
|
1322
|
+
confirmOpen={confirmOpen}
|
|
1323
|
+
hints={hints}
|
|
1324
|
+
focusedWf={focusedWf}
|
|
1325
|
+
/>
|
|
1326
|
+
|
|
1327
|
+
{confirmOpen && focusedWf ? (
|
|
1328
|
+
<ConfirmModal theme={theme} workflow={focusedWf} agent={agent} />
|
|
1329
|
+
) : null}
|
|
1330
|
+
</box>
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ─── Public class API ──────────────────────────
|
|
1335
|
+
|
|
1336
|
+
export interface WorkflowPickerPanelOptions {
|
|
1337
|
+
agent: AgentType;
|
|
1338
|
+
/** Pre-loaded workflows to show. Must already be filtered to `agent`. */
|
|
1339
|
+
workflows: WorkflowWithMetadata[];
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Imperative shell around the React picker tree — mirrors the
|
|
1344
|
+
* {@link OrchestratorPanel} lifecycle so both panels can be used
|
|
1345
|
+
* interchangeably by the CLI command layer.
|
|
1346
|
+
*/
|
|
1347
|
+
export class WorkflowPickerPanel {
|
|
1348
|
+
private renderer: CliRenderer;
|
|
1349
|
+
private root: Root;
|
|
1350
|
+
private destroyed = false;
|
|
1351
|
+
private resolveSelection: ((r: WorkflowPickerResult | null) => void) | null =
|
|
1352
|
+
null;
|
|
1353
|
+
private selectionPromise: Promise<WorkflowPickerResult | null>;
|
|
1354
|
+
|
|
1355
|
+
private constructor(
|
|
1356
|
+
renderer: CliRenderer,
|
|
1357
|
+
options: WorkflowPickerPanelOptions,
|
|
1358
|
+
) {
|
|
1359
|
+
this.renderer = renderer;
|
|
1360
|
+
this.selectionPromise = new Promise((resolve) => {
|
|
1361
|
+
this.resolveSelection = resolve;
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
const theme = buildPickerTheme(resolveTheme(renderer.themeMode));
|
|
1365
|
+
this.root = createRoot(renderer);
|
|
1366
|
+
this.root.render(
|
|
1367
|
+
<ErrorBoundary
|
|
1368
|
+
fallback={(err) => (
|
|
1369
|
+
<box
|
|
1370
|
+
width="100%"
|
|
1371
|
+
height="100%"
|
|
1372
|
+
justifyContent="center"
|
|
1373
|
+
alignItems="center"
|
|
1374
|
+
backgroundColor={theme.background}
|
|
1375
|
+
>
|
|
1376
|
+
<text>
|
|
1377
|
+
<span fg={theme.error}>
|
|
1378
|
+
{`Fatal render error: ${err.message}`}
|
|
1379
|
+
</span>
|
|
1380
|
+
</text>
|
|
1381
|
+
</box>
|
|
1382
|
+
)}
|
|
1383
|
+
>
|
|
1384
|
+
<WorkflowPicker
|
|
1385
|
+
theme={theme}
|
|
1386
|
+
agent={options.agent}
|
|
1387
|
+
workflows={options.workflows}
|
|
1388
|
+
onSubmit={(result) => this.handleSubmit(result)}
|
|
1389
|
+
onCancel={() => this.handleCancel()}
|
|
1390
|
+
/>
|
|
1391
|
+
</ErrorBoundary>,
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Create a new WorkflowPickerPanel. Initialises a CLI renderer and
|
|
1397
|
+
* mounts the interactive tree. The caller should `await
|
|
1398
|
+
* waitForSelection()` and then call `destroy()` regardless of outcome.
|
|
1399
|
+
*/
|
|
1400
|
+
static async create(
|
|
1401
|
+
options: WorkflowPickerPanelOptions,
|
|
1402
|
+
): Promise<WorkflowPickerPanel> {
|
|
1403
|
+
const renderer = await createCliRenderer({
|
|
1404
|
+
exitOnCtrlC: false,
|
|
1405
|
+
exitSignals: [
|
|
1406
|
+
"SIGTERM",
|
|
1407
|
+
"SIGQUIT",
|
|
1408
|
+
"SIGABRT",
|
|
1409
|
+
"SIGHUP",
|
|
1410
|
+
"SIGPIPE",
|
|
1411
|
+
"SIGBUS",
|
|
1412
|
+
"SIGFPE",
|
|
1413
|
+
],
|
|
1414
|
+
});
|
|
1415
|
+
return new WorkflowPickerPanel(renderer, options);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** Create with an externally-provided renderer (e.g. a test renderer). */
|
|
1419
|
+
static createWithRenderer(
|
|
1420
|
+
renderer: CliRenderer,
|
|
1421
|
+
options: WorkflowPickerPanelOptions,
|
|
1422
|
+
): WorkflowPickerPanel {
|
|
1423
|
+
return new WorkflowPickerPanel(renderer, options);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Resolve with the user's selection once they confirm, or `null` if
|
|
1428
|
+
* they exit the picker without committing. Idempotent — subsequent
|
|
1429
|
+
* calls return the same promise.
|
|
1430
|
+
*/
|
|
1431
|
+
waitForSelection(): Promise<WorkflowPickerResult | null> {
|
|
1432
|
+
return this.selectionPromise;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** Tear down the CLI renderer. Idempotent. */
|
|
1436
|
+
destroy(): void {
|
|
1437
|
+
if (this.destroyed) return;
|
|
1438
|
+
this.destroyed = true;
|
|
1439
|
+
// Ensure anyone still awaiting the selection promise is released.
|
|
1440
|
+
if (this.resolveSelection) {
|
|
1441
|
+
this.resolveSelection(null);
|
|
1442
|
+
this.resolveSelection = null;
|
|
1443
|
+
}
|
|
1444
|
+
try {
|
|
1445
|
+
this.renderer.destroy();
|
|
1446
|
+
} catch {}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
private handleSubmit(result: WorkflowPickerResult): void {
|
|
1450
|
+
if (this.resolveSelection) {
|
|
1451
|
+
this.resolveSelection(result);
|
|
1452
|
+
this.resolveSelection = null;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
private handleCancel(): void {
|
|
1457
|
+
if (this.resolveSelection) {
|
|
1458
|
+
this.resolveSelection(null);
|
|
1459
|
+
this.resolveSelection = null;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|