@bastani/atomic 0.5.12-0 → 0.5.12-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* declared `WorkflowInput`). Free-form workflows fall back to
|
|
11
11
|
* a single `prompt` text field.
|
|
12
12
|
*
|
|
13
|
-
* Pressing ⌃
|
|
13
|
+
* Pressing ⌃d in the prompt phase validates required fields and opens a
|
|
14
14
|
* CONFIRM modal that shows the fully-composed shell command before
|
|
15
15
|
* submission. y/↵ confirms, n/esc cancels back to the form.
|
|
16
16
|
*
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import {
|
|
30
30
|
createCliRenderer,
|
|
31
31
|
type CliRenderer,
|
|
32
|
+
type KeyEvent,
|
|
32
33
|
type TextareaRenderable,
|
|
33
34
|
} from "@opentui/core";
|
|
34
35
|
import {
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
useKeyboard,
|
|
37
38
|
type Root,
|
|
38
39
|
} from "@opentui/react";
|
|
39
|
-
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
|
40
|
+
import { useState, useEffect, useMemo, useRef, useCallback, useContext, createContext, memo } from "react";
|
|
40
41
|
import { useLatest } from "./hooks.ts";
|
|
41
42
|
import { resolveTheme, type TerminalTheme } from "../runtime/theme.ts";
|
|
42
43
|
import type { AgentType, WorkflowInput } from "../types.ts";
|
|
@@ -67,13 +68,12 @@ export interface PickerTheme {
|
|
|
67
68
|
borderActive: string;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
export function buildPickerTheme(base: TerminalTheme): PickerTheme {
|
|
71
|
+
export function buildPickerTheme(base: TerminalTheme, isDark: boolean): PickerTheme {
|
|
71
72
|
// For dark mode the prototype values track Catppuccin Mocha. For light
|
|
72
73
|
// mode we derive muted variants from the base palette — the specific
|
|
73
74
|
// extras (`info`, `mauve`, the three-level background ladder) have no
|
|
74
75
|
// direct entries in `TerminalTheme`, so we pick close-enough Catppuccin
|
|
75
76
|
// values to keep the picker visually consistent with the orchestrator.
|
|
76
|
-
const isDark = base.bg !== "#eff1f5";
|
|
77
77
|
return {
|
|
78
78
|
background: base.bg,
|
|
79
79
|
backgroundPanel: isDark ? "#181825" : "#e6e9ef",
|
|
@@ -93,6 +93,17 @@ export function buildPickerTheme(base: TerminalTheme): PickerTheme {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ─── Theme Context ─────────────────────────────
|
|
97
|
+
// Avoids drilling `theme` through every component in the tree.
|
|
98
|
+
|
|
99
|
+
const PickerThemeContext = createContext<PickerTheme | null>(null);
|
|
100
|
+
|
|
101
|
+
function usePickerTheme(): PickerTheme {
|
|
102
|
+
const theme = useContext(PickerThemeContext);
|
|
103
|
+
if (!theme) throw new Error("usePickerTheme must be used within a PickerThemeContext provider");
|
|
104
|
+
return theme;
|
|
105
|
+
}
|
|
106
|
+
|
|
96
107
|
// ─── Types ──────────────────────────────────────
|
|
97
108
|
|
|
98
109
|
type Source = "local" | "global" | "builtin";
|
|
@@ -115,6 +126,10 @@ const DEFAULT_PROMPT_INPUT: WorkflowInput = {
|
|
|
115
126
|
placeholder: "describe your task…",
|
|
116
127
|
};
|
|
117
128
|
|
|
129
|
+
/** Stable single-element array for free-form workflows — avoids allocating
|
|
130
|
+
* a new `[DEFAULT_PROMPT_INPUT]` on every useMemo recomputation. */
|
|
131
|
+
const DEFAULT_FIELDS: WorkflowInput[] = [DEFAULT_PROMPT_INPUT];
|
|
132
|
+
|
|
118
133
|
// ─── Helpers ────────────────────────────────────
|
|
119
134
|
|
|
120
135
|
const SOURCE_DISPLAY: Record<Source, string> = {
|
|
@@ -135,6 +150,13 @@ const SOURCE_COLOR: Record<Source, keyof PickerTheme> = {
|
|
|
135
150
|
builtin: "info",
|
|
136
151
|
};
|
|
137
152
|
|
|
153
|
+
/** Higher number wins when two workflows share a name. */
|
|
154
|
+
const SOURCE_PRECEDENCE: Record<Source, number> = {
|
|
155
|
+
global: 0,
|
|
156
|
+
local: 1,
|
|
157
|
+
builtin: 2,
|
|
158
|
+
};
|
|
159
|
+
|
|
138
160
|
/**
|
|
139
161
|
* Subsequence fuzzy match — Telescope-style. Returns a score (lower =
|
|
140
162
|
* better) or null for no match. Adjacent matches are rewarded; jumps over
|
|
@@ -175,13 +197,31 @@ type ListRow =
|
|
|
175
197
|
| { kind: "section"; source: Source }
|
|
176
198
|
| { kind: "entry"; entry: ListEntry };
|
|
177
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Deduplicate workflows by name using builtin > local > global precedence.
|
|
202
|
+
* When two workflows share a name, only the higher-precedence entry is kept.
|
|
203
|
+
*/
|
|
204
|
+
export function deduplicateByName(
|
|
205
|
+
workflows: WorkflowWithMetadata[],
|
|
206
|
+
): WorkflowWithMetadata[] {
|
|
207
|
+
const byName = new Map<string, WorkflowWithMetadata>();
|
|
208
|
+
for (const wf of workflows) {
|
|
209
|
+
const existing = byName.get(wf.name);
|
|
210
|
+
if (!existing || SOURCE_PRECEDENCE[wf.source] > SOURCE_PRECEDENCE[existing.source]) {
|
|
211
|
+
byName.set(wf.name, wf);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return Array.from(byName.values());
|
|
215
|
+
}
|
|
216
|
+
|
|
178
217
|
export function buildEntries(
|
|
179
218
|
query: string,
|
|
180
219
|
workflows: WorkflowWithMetadata[],
|
|
181
220
|
): ListEntry[] {
|
|
221
|
+
const deduped = deduplicateByName(workflows);
|
|
182
222
|
type Scored = { wf: WorkflowWithMetadata; score: number };
|
|
183
223
|
const scored: Scored[] = [];
|
|
184
|
-
for (const wf of
|
|
224
|
+
for (const wf of deduped) {
|
|
185
225
|
const nameScore = fuzzyMatch(query, wf.name);
|
|
186
226
|
const descScore = fuzzyMatch(query, wf.description);
|
|
187
227
|
const best =
|
|
@@ -240,13 +280,12 @@ export function isFieldValid(field: WorkflowInput, value: string): boolean {
|
|
|
240
280
|
|
|
241
281
|
// ─── Components ─────────────────────────────────
|
|
242
282
|
|
|
243
|
-
function SectionLabel({
|
|
244
|
-
theme,
|
|
283
|
+
const SectionLabel = memo(function SectionLabel({
|
|
245
284
|
label,
|
|
246
285
|
}: {
|
|
247
|
-
theme: PickerTheme;
|
|
248
286
|
label: string;
|
|
249
287
|
}) {
|
|
288
|
+
const theme = usePickerTheme();
|
|
250
289
|
return (
|
|
251
290
|
<box height={1} flexDirection="row">
|
|
252
291
|
<text>
|
|
@@ -257,19 +296,18 @@ function SectionLabel({
|
|
|
257
296
|
</text>
|
|
258
297
|
</box>
|
|
259
298
|
);
|
|
260
|
-
}
|
|
299
|
+
});
|
|
261
300
|
|
|
262
301
|
function FilterBar({
|
|
263
|
-
theme,
|
|
264
302
|
query,
|
|
265
303
|
focused,
|
|
266
304
|
onInput,
|
|
267
305
|
}: {
|
|
268
|
-
theme: PickerTheme;
|
|
269
306
|
query: string;
|
|
270
307
|
focused: boolean;
|
|
271
308
|
onInput: (value: string) => void;
|
|
272
309
|
}) {
|
|
310
|
+
const theme = usePickerTheme();
|
|
273
311
|
return (
|
|
274
312
|
<box
|
|
275
313
|
minHeight={3}
|
|
@@ -301,37 +339,38 @@ function FilterBar({
|
|
|
301
339
|
);
|
|
302
340
|
}
|
|
303
341
|
|
|
304
|
-
function WorkflowList({
|
|
305
|
-
theme,
|
|
342
|
+
const WorkflowList = memo(function WorkflowList({
|
|
306
343
|
rows,
|
|
307
344
|
focusedEntryIdx,
|
|
308
345
|
}: {
|
|
309
|
-
theme: PickerTheme;
|
|
310
346
|
rows: ListRow[];
|
|
311
347
|
focusedEntryIdx: number;
|
|
312
348
|
}) {
|
|
313
|
-
|
|
314
|
-
return (
|
|
315
|
-
<box paddingLeft={2} paddingTop={2}>
|
|
316
|
-
<text>
|
|
317
|
-
<span fg={theme.textDim}>no matches</span>
|
|
318
|
-
</text>
|
|
319
|
-
</box>
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
|
|
349
|
+
const theme = usePickerTheme();
|
|
323
350
|
// Pre-compute entry indices so the render pass is side-effect-free.
|
|
351
|
+
// Must live before any early return to satisfy the Rules of Hooks.
|
|
324
352
|
const entryIndexByRow = useMemo(() => {
|
|
325
353
|
const map = new Map<number, number>();
|
|
326
354
|
let counter = 0;
|
|
327
355
|
for (let i = 0; i < rows.length; i++) {
|
|
328
|
-
|
|
356
|
+
const row = rows[i];
|
|
357
|
+
if (row && row.kind === "entry") {
|
|
329
358
|
map.set(i, counter++);
|
|
330
359
|
}
|
|
331
360
|
}
|
|
332
361
|
return map;
|
|
333
362
|
}, [rows]);
|
|
334
363
|
|
|
364
|
+
if (rows.length === 0) {
|
|
365
|
+
return (
|
|
366
|
+
<box paddingLeft={2} paddingTop={2}>
|
|
367
|
+
<text>
|
|
368
|
+
<span fg={theme.textDim}>no matches</span>
|
|
369
|
+
</text>
|
|
370
|
+
</box>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
335
374
|
return (
|
|
336
375
|
<box flexDirection="column">
|
|
337
376
|
{rows.map((row, i) => {
|
|
@@ -339,7 +378,7 @@ function WorkflowList({
|
|
|
339
378
|
const src = row.source;
|
|
340
379
|
return (
|
|
341
380
|
<box
|
|
342
|
-
key={`
|
|
381
|
+
key={`section-${src}`}
|
|
343
382
|
height={2}
|
|
344
383
|
paddingTop={1}
|
|
345
384
|
paddingLeft={2}
|
|
@@ -361,7 +400,7 @@ function WorkflowList({
|
|
|
361
400
|
|
|
362
401
|
return (
|
|
363
402
|
<box
|
|
364
|
-
key={`
|
|
403
|
+
key={`wf-${wf.name}`}
|
|
365
404
|
height={1}
|
|
366
405
|
flexDirection="row"
|
|
367
406
|
backgroundColor={isFocused ? theme.border : "transparent"}
|
|
@@ -381,20 +420,21 @@ function WorkflowList({
|
|
|
381
420
|
})}
|
|
382
421
|
</box>
|
|
383
422
|
);
|
|
384
|
-
}
|
|
423
|
+
});
|
|
385
424
|
|
|
386
|
-
function ArgumentRow({
|
|
387
|
-
theme,
|
|
425
|
+
const ArgumentRow = memo(function ArgumentRow({
|
|
388
426
|
field,
|
|
389
427
|
}: {
|
|
390
|
-
theme: PickerTheme;
|
|
391
428
|
field: WorkflowInput;
|
|
392
429
|
}) {
|
|
430
|
+
const theme = usePickerTheme();
|
|
393
431
|
const isRequired = field.required ?? false;
|
|
394
432
|
const tagCol = isRequired ? theme.warning : theme.textDim;
|
|
395
433
|
const tagLabel = isRequired ? "required" : "optional";
|
|
396
|
-
const
|
|
397
|
-
field.type === "enum" && field.values && field.values.length > 0
|
|
434
|
+
const enumValues =
|
|
435
|
+
field.type === "enum" && field.values && field.values.length > 0
|
|
436
|
+
? field.values
|
|
437
|
+
: null;
|
|
398
438
|
|
|
399
439
|
return (
|
|
400
440
|
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
@@ -418,10 +458,10 @@ function ArgumentRow({
|
|
|
418
458
|
</box>
|
|
419
459
|
) : null}
|
|
420
460
|
|
|
421
|
-
{
|
|
461
|
+
{enumValues ? (
|
|
422
462
|
<box height={1}>
|
|
423
463
|
<text>
|
|
424
|
-
<span fg={theme.textDim}>{
|
|
464
|
+
<span fg={theme.textDim}>{enumValues.join(" · ")}</span>
|
|
425
465
|
</text>
|
|
426
466
|
</box>
|
|
427
467
|
) : null}
|
|
@@ -429,17 +469,16 @@ function ArgumentRow({
|
|
|
429
469
|
<box height={1} />
|
|
430
470
|
</box>
|
|
431
471
|
);
|
|
432
|
-
}
|
|
472
|
+
});
|
|
433
473
|
|
|
434
|
-
function Preview({
|
|
435
|
-
theme,
|
|
474
|
+
const Preview = memo(function Preview({
|
|
436
475
|
wf,
|
|
437
476
|
}: {
|
|
438
|
-
theme: PickerTheme;
|
|
439
477
|
wf: WorkflowWithMetadata;
|
|
440
478
|
}) {
|
|
441
|
-
const
|
|
442
|
-
|
|
479
|
+
const theme = usePickerTheme();
|
|
480
|
+
const args: readonly WorkflowInput[] =
|
|
481
|
+
wf.inputs.length > 0 ? wf.inputs : DEFAULT_FIELDS;
|
|
443
482
|
|
|
444
483
|
return (
|
|
445
484
|
<box
|
|
@@ -475,22 +514,21 @@ function Preview({
|
|
|
475
514
|
|
|
476
515
|
<box height={2} />
|
|
477
516
|
|
|
478
|
-
<SectionLabel
|
|
517
|
+
<SectionLabel label="ARGUMENTS" />
|
|
479
518
|
<box height={1} />
|
|
480
519
|
{args.map((f) => (
|
|
481
|
-
<ArgumentRow key={f.name}
|
|
520
|
+
<ArgumentRow key={f.name} field={f} />
|
|
482
521
|
))}
|
|
483
522
|
</box>
|
|
484
523
|
);
|
|
485
|
-
}
|
|
524
|
+
});
|
|
486
525
|
|
|
487
526
|
function EmptyPreview({
|
|
488
|
-
theme,
|
|
489
527
|
query,
|
|
490
528
|
}: {
|
|
491
|
-
theme: PickerTheme;
|
|
492
529
|
query: string;
|
|
493
530
|
}) {
|
|
531
|
+
const theme = usePickerTheme();
|
|
494
532
|
return (
|
|
495
533
|
<box
|
|
496
534
|
flexDirection="column"
|
|
@@ -529,20 +567,22 @@ function EmptyPreview({
|
|
|
529
567
|
const TEXT_FIELD_LINES = 3;
|
|
530
568
|
|
|
531
569
|
|
|
570
|
+
const NOOP_CHANGE_REF: React.RefObject<((value: string) => void) | null> = { current: null };
|
|
571
|
+
|
|
532
572
|
function TextAreaContent({
|
|
533
|
-
theme,
|
|
534
573
|
value,
|
|
535
574
|
placeholder,
|
|
536
575
|
focused,
|
|
537
576
|
onChangeRef,
|
|
538
577
|
}: {
|
|
539
|
-
theme: PickerTheme;
|
|
540
578
|
value: string;
|
|
541
579
|
placeholder: string;
|
|
542
580
|
focused: boolean;
|
|
543
|
-
onChangeRef
|
|
581
|
+
onChangeRef?: React.RefObject<((value: string) => void) | null>;
|
|
544
582
|
}) {
|
|
583
|
+
const theme = usePickerTheme();
|
|
545
584
|
const ref = useRef<TextareaRenderable>(null);
|
|
585
|
+
const changeRef = onChangeRef ?? NOOP_CHANGE_REF;
|
|
546
586
|
|
|
547
587
|
// Sync external value → textarea when it diverges (e.g. initial value).
|
|
548
588
|
useEffect(() => {
|
|
@@ -551,18 +591,14 @@ function TextAreaContent({
|
|
|
551
591
|
}
|
|
552
592
|
}, [value]);
|
|
553
593
|
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
ta.onContentChange = undefined;
|
|
563
|
-
};
|
|
564
|
-
}, [onChangeRef]);
|
|
565
|
-
|
|
594
|
+
// Wire onContentChange as a prop so the reconciler sets it during the
|
|
595
|
+
// commit phase (via the constructor and setProperty's default branch),
|
|
596
|
+
// guaranteeing it is active before the textarea can receive key events.
|
|
597
|
+
// The previous useEffect approach deferred setup as a passive effect
|
|
598
|
+
// (ConcurrentRoot schedules these via setTimeout) — creating a window
|
|
599
|
+
// where keystrokes reached the focused textarea before the listener
|
|
600
|
+
// existed, silently dropping content-change notifications and leaving
|
|
601
|
+
// fieldValues stale.
|
|
566
602
|
return (
|
|
567
603
|
<textarea
|
|
568
604
|
ref={ref}
|
|
@@ -576,23 +612,25 @@ function TextAreaContent({
|
|
|
576
612
|
placeholderColor={theme.textDim}
|
|
577
613
|
wrapMode="word"
|
|
578
614
|
flexGrow={1}
|
|
615
|
+
onContentChange={() => {
|
|
616
|
+
changeRef.current?.(ref.current?.plainText ?? "");
|
|
617
|
+
}}
|
|
579
618
|
/>
|
|
580
619
|
);
|
|
581
620
|
}
|
|
582
621
|
|
|
583
622
|
function StringContent({
|
|
584
|
-
theme,
|
|
585
623
|
value,
|
|
586
624
|
placeholder,
|
|
587
625
|
focused,
|
|
588
626
|
onInput,
|
|
589
627
|
}: {
|
|
590
|
-
theme: PickerTheme;
|
|
591
628
|
value: string;
|
|
592
629
|
placeholder: string;
|
|
593
630
|
focused: boolean;
|
|
594
631
|
onInput: (value: string) => void;
|
|
595
632
|
}) {
|
|
633
|
+
const theme = usePickerTheme();
|
|
596
634
|
return (
|
|
597
635
|
<input
|
|
598
636
|
value={value}
|
|
@@ -609,16 +647,15 @@ function StringContent({
|
|
|
609
647
|
}
|
|
610
648
|
|
|
611
649
|
function EnumContent({
|
|
612
|
-
theme,
|
|
613
650
|
values,
|
|
614
651
|
selected,
|
|
615
652
|
focused,
|
|
616
653
|
}: {
|
|
617
|
-
theme: PickerTheme;
|
|
618
654
|
values: string[];
|
|
619
655
|
selected: string;
|
|
620
656
|
focused: boolean;
|
|
621
657
|
}) {
|
|
658
|
+
const theme = usePickerTheme();
|
|
622
659
|
return (
|
|
623
660
|
<box height={1} flexDirection="row">
|
|
624
661
|
{values.map((v, i) => {
|
|
@@ -652,21 +689,20 @@ function EnumContent({
|
|
|
652
689
|
);
|
|
653
690
|
}
|
|
654
691
|
|
|
655
|
-
function Field({
|
|
656
|
-
theme,
|
|
692
|
+
const Field = memo(function Field({
|
|
657
693
|
field,
|
|
658
694
|
value,
|
|
659
695
|
focused,
|
|
660
|
-
|
|
696
|
+
onFieldInput,
|
|
661
697
|
onTextChangeRef,
|
|
662
698
|
}: {
|
|
663
|
-
theme: PickerTheme;
|
|
664
699
|
field: WorkflowInput;
|
|
665
700
|
value: string;
|
|
666
701
|
focused: boolean;
|
|
667
|
-
|
|
668
|
-
onTextChangeRef
|
|
702
|
+
onFieldInput: (fieldName: string, value: string) => void;
|
|
703
|
+
onTextChangeRef?: React.RefObject<((value: string) => void) | null>;
|
|
669
704
|
}) {
|
|
705
|
+
const theme = usePickerTheme();
|
|
670
706
|
const borderCol = focused ? theme.primary : theme.border;
|
|
671
707
|
const bgCol = focused ? theme.backgroundPanel : theme.backgroundElement;
|
|
672
708
|
|
|
@@ -676,6 +712,12 @@ function Field({
|
|
|
676
712
|
const tagLabel = field.required ? "required" : "optional";
|
|
677
713
|
const captionDesc = field.description ? " · " + field.description : "";
|
|
678
714
|
|
|
715
|
+
// Bind the field name once so the parent doesn't need a per-field closure.
|
|
716
|
+
const onInput = useCallback(
|
|
717
|
+
(v: string) => onFieldInput(field.name, v),
|
|
718
|
+
[onFieldInput, field.name],
|
|
719
|
+
);
|
|
720
|
+
|
|
679
721
|
return (
|
|
680
722
|
<box flexDirection="column">
|
|
681
723
|
<box
|
|
@@ -693,7 +735,6 @@ function Field({
|
|
|
693
735
|
>
|
|
694
736
|
{field.type === "text" ? (
|
|
695
737
|
<TextAreaContent
|
|
696
|
-
theme={theme}
|
|
697
738
|
value={value}
|
|
698
739
|
placeholder={field.placeholder ?? ""}
|
|
699
740
|
focused={focused}
|
|
@@ -701,7 +742,6 @@ function Field({
|
|
|
701
742
|
/>
|
|
702
743
|
) : field.type === "string" ? (
|
|
703
744
|
<StringContent
|
|
704
|
-
theme={theme}
|
|
705
745
|
value={value}
|
|
706
746
|
placeholder={field.placeholder ?? ""}
|
|
707
747
|
focused={focused}
|
|
@@ -709,7 +749,6 @@ function Field({
|
|
|
709
749
|
/>
|
|
710
750
|
) : field.type === "enum" ? (
|
|
711
751
|
<EnumContent
|
|
712
|
-
theme={theme}
|
|
713
752
|
values={field.values ?? []}
|
|
714
753
|
selected={value}
|
|
715
754
|
focused={focused}
|
|
@@ -729,10 +768,9 @@ function Field({
|
|
|
729
768
|
<box height={1} />
|
|
730
769
|
</box>
|
|
731
770
|
);
|
|
732
|
-
}
|
|
771
|
+
});
|
|
733
772
|
|
|
734
773
|
function InputPhase({
|
|
735
|
-
theme,
|
|
736
774
|
workflow,
|
|
737
775
|
agent,
|
|
738
776
|
fields,
|
|
@@ -741,15 +779,15 @@ function InputPhase({
|
|
|
741
779
|
onFieldInput,
|
|
742
780
|
onTextChangeRef,
|
|
743
781
|
}: {
|
|
744
|
-
theme: PickerTheme;
|
|
745
782
|
workflow: WorkflowWithMetadata;
|
|
746
783
|
agent: AgentType;
|
|
747
|
-
fields: WorkflowInput[];
|
|
784
|
+
fields: readonly WorkflowInput[];
|
|
748
785
|
values: Record<string, string>;
|
|
749
786
|
focusedFieldIdx: number;
|
|
750
787
|
onFieldInput: (fieldName: string, value: string) => void;
|
|
751
788
|
onTextChangeRef: React.RefObject<((value: string) => void) | null>;
|
|
752
789
|
}) {
|
|
790
|
+
const theme = usePickerTheme();
|
|
753
791
|
const isStructured = workflow.inputs.length > 0;
|
|
754
792
|
|
|
755
793
|
return (
|
|
@@ -816,12 +854,15 @@ function InputPhase({
|
|
|
816
854
|
{fields.map((f, i) => (
|
|
817
855
|
<Field
|
|
818
856
|
key={f.name}
|
|
819
|
-
theme={theme}
|
|
820
857
|
field={f}
|
|
821
858
|
value={values[f.name] ?? ""}
|
|
822
859
|
focused={i === focusedFieldIdx}
|
|
823
|
-
|
|
824
|
-
onTextChangeRef={
|
|
860
|
+
onFieldInput={onFieldInput}
|
|
861
|
+
onTextChangeRef={
|
|
862
|
+
f.type === "text" && i === focusedFieldIdx
|
|
863
|
+
? onTextChangeRef
|
|
864
|
+
: undefined
|
|
865
|
+
}
|
|
825
866
|
/>
|
|
826
867
|
))}
|
|
827
868
|
</box>
|
|
@@ -829,14 +870,13 @@ function InputPhase({
|
|
|
829
870
|
}
|
|
830
871
|
|
|
831
872
|
function ConfirmModal({
|
|
832
|
-
theme,
|
|
833
873
|
workflow,
|
|
834
874
|
agent,
|
|
835
875
|
}: {
|
|
836
|
-
theme: PickerTheme;
|
|
837
876
|
workflow: WorkflowWithMetadata;
|
|
838
877
|
agent: AgentType;
|
|
839
878
|
}) {
|
|
879
|
+
const theme = usePickerTheme();
|
|
840
880
|
return (
|
|
841
881
|
<box
|
|
842
882
|
position="absolute"
|
|
@@ -897,16 +937,28 @@ function ConfirmModal({
|
|
|
897
937
|
);
|
|
898
938
|
}
|
|
899
939
|
|
|
900
|
-
// Stable hint arrays —
|
|
901
|
-
|
|
940
|
+
// Stable hint arrays — pre-built so they never create new references.
|
|
941
|
+
type Hint = { key: string; label: string; dim?: boolean };
|
|
942
|
+
|
|
943
|
+
const PICK_HINTS: Hint[] = [
|
|
902
944
|
{ key: "↑↓", label: "navigate" },
|
|
903
945
|
{ key: "↵", label: "select" },
|
|
904
946
|
{ key: "esc", label: "quit" },
|
|
905
947
|
];
|
|
906
|
-
const CONFIRM_HINTS:
|
|
948
|
+
const CONFIRM_HINTS: Hint[] = [
|
|
907
949
|
{ key: "y", label: "submit" },
|
|
908
950
|
{ key: "n", label: "cancel" },
|
|
909
951
|
];
|
|
952
|
+
const PROMPT_HINTS_VALID: Hint[] = [
|
|
953
|
+
{ key: "tab", label: "to navigate forward" },
|
|
954
|
+
{ key: "shift+tab", label: "to navigate backward" },
|
|
955
|
+
{ key: "ctrl+d", label: "to run" },
|
|
956
|
+
];
|
|
957
|
+
const PROMPT_HINTS_INVALID: Hint[] = [
|
|
958
|
+
{ key: "tab", label: "to navigate forward" },
|
|
959
|
+
{ key: "shift+tab", label: "to navigate backward" },
|
|
960
|
+
{ key: "ctrl+d", label: "to run", dim: true },
|
|
961
|
+
];
|
|
910
962
|
|
|
911
963
|
// Per-agent brand color used as the Header pill background.
|
|
912
964
|
const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
|
|
@@ -915,19 +967,18 @@ const AGENT_PILL_COLOR: Record<AgentType, keyof PickerTheme> = {
|
|
|
915
967
|
opencode: "mauve",
|
|
916
968
|
};
|
|
917
969
|
|
|
918
|
-
function Header({
|
|
919
|
-
theme,
|
|
970
|
+
const Header = memo(function Header({
|
|
920
971
|
phase,
|
|
921
972
|
confirmOpen,
|
|
922
973
|
selectedAgent,
|
|
923
974
|
scopedCount,
|
|
924
975
|
}: {
|
|
925
|
-
theme: PickerTheme;
|
|
926
976
|
phase: Phase;
|
|
927
977
|
confirmOpen: boolean;
|
|
928
978
|
selectedAgent: AgentType;
|
|
929
979
|
scopedCount: number;
|
|
930
980
|
}) {
|
|
981
|
+
const theme = usePickerTheme();
|
|
931
982
|
const phaseLabel = confirmOpen
|
|
932
983
|
? "confirm"
|
|
933
984
|
: phase === "pick"
|
|
@@ -965,21 +1016,20 @@ function Header({
|
|
|
965
1016
|
</text>
|
|
966
1017
|
</box>
|
|
967
1018
|
);
|
|
968
|
-
}
|
|
1019
|
+
});
|
|
969
1020
|
|
|
970
|
-
function Statusline({
|
|
971
|
-
theme,
|
|
1021
|
+
const Statusline = memo(function Statusline({
|
|
972
1022
|
phase,
|
|
973
1023
|
confirmOpen,
|
|
974
1024
|
hints,
|
|
975
1025
|
focusedWf,
|
|
976
1026
|
}: {
|
|
977
|
-
theme: PickerTheme;
|
|
978
1027
|
phase: Phase;
|
|
979
1028
|
confirmOpen: boolean;
|
|
980
1029
|
hints: { key: string; label: string; dim?: boolean }[];
|
|
981
1030
|
focusedWf: WorkflowWithMetadata | undefined;
|
|
982
1031
|
}) {
|
|
1032
|
+
const theme = usePickerTheme();
|
|
983
1033
|
const modeLabel = confirmOpen
|
|
984
1034
|
? "CONFIRM"
|
|
985
1035
|
: phase === "pick"
|
|
@@ -1033,166 +1083,131 @@ function Statusline({
|
|
|
1033
1083
|
</box>
|
|
1034
1084
|
</box>
|
|
1035
1085
|
);
|
|
1036
|
-
}
|
|
1086
|
+
});
|
|
1037
1087
|
|
|
1038
|
-
// ───
|
|
1088
|
+
// ─── Keyboard hook ─────────────────────────────
|
|
1039
1089
|
|
|
1040
|
-
interface
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1090
|
+
interface PickerKeyboardState {
|
|
1091
|
+
entries: ListEntry[];
|
|
1092
|
+
clampedEntryIdx: number;
|
|
1093
|
+
savedEntryIdx: number;
|
|
1094
|
+
focusedWf: WorkflowWithMetadata | undefined;
|
|
1095
|
+
fieldValues: Record<string, string>;
|
|
1096
|
+
isFormValid: boolean;
|
|
1097
|
+
invalidFieldIndices: number[];
|
|
1098
|
+
currentFields: readonly WorkflowInput[];
|
|
1099
|
+
currentField: WorkflowInput | undefined;
|
|
1100
|
+
phase: Phase;
|
|
1101
|
+
confirmOpen: boolean;
|
|
1044
1102
|
onSubmit: (result: WorkflowPickerResult) => void;
|
|
1045
1103
|
onCancel: () => void;
|
|
1104
|
+
setPhase: (p: Phase) => void;
|
|
1105
|
+
setEntryIdx: React.Dispatch<React.SetStateAction<number>>;
|
|
1106
|
+
setSavedEntryIdx: (i: number) => void;
|
|
1107
|
+
setFieldValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
1108
|
+
setFocusedFieldIdx: React.Dispatch<React.SetStateAction<number>>;
|
|
1109
|
+
setConfirmOpen: (open: boolean) => void;
|
|
1046
1110
|
}
|
|
1047
1111
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const
|
|
1056
|
-
const
|
|
1057
|
-
const
|
|
1058
|
-
const
|
|
1059
|
-
const
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
const
|
|
1065
|
-
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
const invalidFieldIndices = useMemo(() => {
|
|
1085
|
-
const out: number[] = [];
|
|
1086
|
-
for (let i = 0; i < currentFields.length; i++) {
|
|
1087
|
-
const f = currentFields[i]!;
|
|
1088
|
-
const v = fieldValues[f.name] ?? "";
|
|
1089
|
-
if (!isFieldValid(f, v)) out.push(i);
|
|
1112
|
+
/**
|
|
1113
|
+
* Encapsulates all keyboard handling for the picker's three phases
|
|
1114
|
+
* (pick, prompt, confirm). Reads state through refs to avoid stale
|
|
1115
|
+
* closures — useKeyboard captures the first callback identity.
|
|
1116
|
+
*/
|
|
1117
|
+
function usePickerKeyboard(state: PickerKeyboardState): void {
|
|
1118
|
+
const onSubmitRef = useLatest(state.onSubmit);
|
|
1119
|
+
const onCancelRef = useLatest(state.onCancel);
|
|
1120
|
+
const entriesRef = useLatest(state.entries);
|
|
1121
|
+
const entryIdxRef = useLatest(state.clampedEntryIdx);
|
|
1122
|
+
const savedEntryIdxRef = useLatest(state.savedEntryIdx);
|
|
1123
|
+
const focusedWfRef = useLatest(state.focusedWf);
|
|
1124
|
+
const fieldValuesRef = useLatest(state.fieldValues);
|
|
1125
|
+
const isFormValidRef = useLatest(state.isFormValid);
|
|
1126
|
+
const invalidFieldIndicesRef = useLatest(state.invalidFieldIndices);
|
|
1127
|
+
const currentFieldsRef = useLatest(state.currentFields);
|
|
1128
|
+
const currentFieldRef = useLatest(state.currentField);
|
|
1129
|
+
const phaseRef = useLatest(state.phase);
|
|
1130
|
+
const confirmOpenRef = useLatest(state.confirmOpen);
|
|
1131
|
+
|
|
1132
|
+
const {
|
|
1133
|
+
setPhase,
|
|
1134
|
+
setEntryIdx,
|
|
1135
|
+
setSavedEntryIdx,
|
|
1136
|
+
setFieldValues,
|
|
1137
|
+
setFocusedFieldIdx,
|
|
1138
|
+
setConfirmOpen,
|
|
1139
|
+
} = state;
|
|
1140
|
+
|
|
1141
|
+
const onConfirmKey = useCallback((key: KeyEvent) => {
|
|
1142
|
+
key.stopPropagation();
|
|
1143
|
+
if (key.name === "y" || key.name === "return") {
|
|
1144
|
+
const wf = focusedWfRef.current;
|
|
1145
|
+
if (!wf) return;
|
|
1146
|
+
onSubmitRef.current({ workflow: wf, inputs: { ...fieldValuesRef.current } });
|
|
1147
|
+
return;
|
|
1090
1148
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
// Wire the textarea change callback so field values stay in sync.
|
|
1096
|
-
// The ref is written here (not in a child) so the parent state
|
|
1097
|
-
// always reflects the latest textarea content.
|
|
1098
|
-
const focusedField = currentField;
|
|
1099
|
-
textChangeRef.current = focusedField
|
|
1100
|
-
? (text: string) => {
|
|
1101
|
-
setFieldValues((prev) => ({ ...prev, [focusedField.name]: text }));
|
|
1102
|
-
}
|
|
1103
|
-
: null;
|
|
1104
|
-
|
|
1105
|
-
// Stable callback for field input — the setter is referentially stable.
|
|
1106
|
-
const onFieldInput = useCallback(
|
|
1107
|
-
(name: string, v: string) => setFieldValues((prev) => ({ ...prev, [name]: v })),
|
|
1108
|
-
[],
|
|
1109
|
-
);
|
|
1110
|
-
|
|
1111
|
-
// Stable refs for values read inside the keyboard handler,
|
|
1112
|
-
// preventing stale closures when useKeyboard holds the first callback.
|
|
1113
|
-
const entriesRef = useLatest(entries);
|
|
1114
|
-
const focusedWfRef = useLatest(focusedWf);
|
|
1115
|
-
const fieldValuesRef = useLatest(fieldValues);
|
|
1116
|
-
const isFormValidRef = useLatest(isFormValid);
|
|
1117
|
-
const invalidFieldIndicesRef = useLatest(invalidFieldIndices);
|
|
1118
|
-
const currentFieldsRef = useLatest(currentFields);
|
|
1119
|
-
const currentFieldRef = useLatest(currentField);
|
|
1120
|
-
const phaseRef = useLatest(phase);
|
|
1121
|
-
const confirmOpenRef = useLatest(confirmOpen);
|
|
1149
|
+
if (key.name === "n" || key.name === "escape") {
|
|
1150
|
+
setConfirmOpen(false);
|
|
1151
|
+
}
|
|
1152
|
+
}, []);
|
|
1122
1153
|
|
|
1123
|
-
|
|
1124
|
-
if (key.
|
|
1125
|
-
|
|
1154
|
+
const onPickKey = useCallback((key: KeyEvent) => {
|
|
1155
|
+
if (key.name === "escape") {
|
|
1156
|
+
key.stopPropagation();
|
|
1157
|
+
onCancelRef.current();
|
|
1126
1158
|
return;
|
|
1127
1159
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
const wf = focusedWfRef.current;
|
|
1132
|
-
if (!wf) return;
|
|
1133
|
-
onSubmit({ workflow: wf, inputs: { ...fieldValuesRef.current } });
|
|
1134
|
-
return;
|
|
1135
|
-
}
|
|
1136
|
-
if (key.name === "n" || key.name === "escape") {
|
|
1137
|
-
setConfirmOpen(false);
|
|
1138
|
-
return;
|
|
1139
|
-
}
|
|
1160
|
+
if (key.name === "up" || (key.ctrl && key.name === "k")) {
|
|
1161
|
+
key.stopPropagation();
|
|
1162
|
+
setEntryIdx(Math.max(0, entryIdxRef.current - 1));
|
|
1140
1163
|
return;
|
|
1141
1164
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
if (wf) {
|
|
1161
|
-
const inputs: WorkflowInput[] =
|
|
1162
|
-
wf.inputs.length > 0
|
|
1163
|
-
? [...wf.inputs]
|
|
1164
|
-
: [DEFAULT_PROMPT_INPUT];
|
|
1165
|
-
const initial: Record<string, string> = {};
|
|
1166
|
-
for (const f of inputs) {
|
|
1167
|
-
initial[f.name] =
|
|
1168
|
-
f.default ??
|
|
1169
|
-
(f.type === "enum" ? (f.values?.[0] ?? "") : "");
|
|
1170
|
-
}
|
|
1171
|
-
setFieldValues(initial);
|
|
1172
|
-
setFocusedFieldIdx(0);
|
|
1173
|
-
setPhase("prompt");
|
|
1165
|
+
if (key.name === "down" || (key.ctrl && key.name === "j")) {
|
|
1166
|
+
key.stopPropagation();
|
|
1167
|
+
setEntryIdx(Math.min(entriesRef.current.length - 1, entryIdxRef.current + 1));
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (key.name === "return") {
|
|
1171
|
+
key.stopPropagation();
|
|
1172
|
+
const wf = focusedWfRef.current;
|
|
1173
|
+
if (wf) {
|
|
1174
|
+
const inputs: readonly WorkflowInput[] =
|
|
1175
|
+
wf.inputs.length > 0
|
|
1176
|
+
? wf.inputs
|
|
1177
|
+
: DEFAULT_FIELDS;
|
|
1178
|
+
const initial: Record<string, string> = {};
|
|
1179
|
+
for (const f of inputs) {
|
|
1180
|
+
initial[f.name] =
|
|
1181
|
+
f.default ??
|
|
1182
|
+
(f.type === "enum" ? (f.values?.[0] ?? "") : "");
|
|
1174
1183
|
}
|
|
1175
|
-
|
|
1184
|
+
setFieldValues(initial);
|
|
1185
|
+
setFocusedFieldIdx(0);
|
|
1186
|
+
setSavedEntryIdx(entryIdxRef.current);
|
|
1187
|
+
setPhase("prompt");
|
|
1176
1188
|
}
|
|
1177
|
-
// All other keys (typing, backspace, arrows) are handled by the
|
|
1178
|
-
// native <input> component in the FilterBar.
|
|
1179
|
-
return;
|
|
1180
1189
|
}
|
|
1190
|
+
}, []);
|
|
1181
1191
|
|
|
1182
|
-
|
|
1192
|
+
const onPromptKey = useCallback((key: KeyEvent) => {
|
|
1183
1193
|
if (key.name === "escape") {
|
|
1194
|
+
key.stopPropagation();
|
|
1195
|
+
setEntryIdx(savedEntryIdxRef.current);
|
|
1184
1196
|
setPhase("pick");
|
|
1185
1197
|
return;
|
|
1186
1198
|
}
|
|
1187
|
-
if (key.ctrl && key.name === "
|
|
1199
|
+
if (key.ctrl && key.name === "d") {
|
|
1200
|
+
key.stopPropagation();
|
|
1188
1201
|
if (!isFormValidRef.current) {
|
|
1189
|
-
|
|
1202
|
+
const firstInvalid = invalidFieldIndicesRef.current[0];
|
|
1203
|
+
if (firstInvalid !== undefined) setFocusedFieldIdx(firstInvalid);
|
|
1190
1204
|
return;
|
|
1191
1205
|
}
|
|
1192
1206
|
setConfirmOpen(true);
|
|
1193
1207
|
return;
|
|
1194
1208
|
}
|
|
1195
1209
|
if (key.name === "tab") {
|
|
1210
|
+
key.stopPropagation();
|
|
1196
1211
|
setFocusedFieldIdx((i: number) => {
|
|
1197
1212
|
const len = currentFieldsRef.current.length;
|
|
1198
1213
|
if (len <= 1) return 0;
|
|
@@ -1203,11 +1218,11 @@ export function WorkflowPicker({
|
|
|
1203
1218
|
const field = currentFieldRef.current;
|
|
1204
1219
|
if (!field) return;
|
|
1205
1220
|
|
|
1206
|
-
// Enum fields use left/right to cycle values.
|
|
1207
1221
|
if (field.type === "enum") {
|
|
1208
1222
|
const values = field.values ?? [];
|
|
1209
1223
|
if (values.length === 0) return;
|
|
1210
1224
|
if (key.name === "left" || key.name === "right") {
|
|
1225
|
+
key.stopPropagation();
|
|
1211
1226
|
setFieldValues((prev: Record<string, string>) => {
|
|
1212
1227
|
const cur = prev[field.name] ?? values[0] ?? "";
|
|
1213
1228
|
const idx = Math.max(0, values.indexOf(cur));
|
|
@@ -1219,100 +1234,192 @@ export function WorkflowPicker({
|
|
|
1219
1234
|
return;
|
|
1220
1235
|
}
|
|
1221
1236
|
|
|
1222
|
-
// For string fields, return advances focus to the next field
|
|
1223
|
-
// (the native <input> fires onSubmit, but we handle it here so
|
|
1224
|
-
// the focus-cycling logic stays in one place).
|
|
1225
1237
|
if (field.type === "string" && key.name === "return") {
|
|
1238
|
+
key.stopPropagation();
|
|
1226
1239
|
setFocusedFieldIdx((i: number) =>
|
|
1227
1240
|
Math.min(currentFieldsRef.current.length - 1, i + 1),
|
|
1228
1241
|
);
|
|
1242
|
+
}
|
|
1243
|
+
}, []);
|
|
1244
|
+
|
|
1245
|
+
useKeyboard((key) => {
|
|
1246
|
+
if (key.ctrl && key.name === "c") {
|
|
1247
|
+
key.stopPropagation();
|
|
1248
|
+
onCancelRef.current();
|
|
1229
1249
|
return;
|
|
1230
1250
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1251
|
+
if (confirmOpenRef.current) return onConfirmKey(key);
|
|
1252
|
+
if (phaseRef.current === "pick") return onPickKey(key);
|
|
1253
|
+
onPromptKey(key);
|
|
1233
1254
|
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// ─── App ────────────────────────────────────────
|
|
1258
|
+
|
|
1259
|
+
interface PickerAppProps {
|
|
1260
|
+
theme: PickerTheme;
|
|
1261
|
+
agent: AgentType;
|
|
1262
|
+
workflows: WorkflowWithMetadata[];
|
|
1263
|
+
onSubmit: (result: WorkflowPickerResult) => void;
|
|
1264
|
+
onCancel: () => void;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
export function WorkflowPicker({
|
|
1268
|
+
theme,
|
|
1269
|
+
agent,
|
|
1270
|
+
workflows,
|
|
1271
|
+
onSubmit,
|
|
1272
|
+
onCancel,
|
|
1273
|
+
}: PickerAppProps) {
|
|
1274
|
+
const [phase, setPhase] = useState<Phase>("pick");
|
|
1275
|
+
const [query, setQuery] = useState("");
|
|
1276
|
+
const [entryIdx, setEntryIdx] = useState(0);
|
|
1277
|
+
const [savedEntryIdx, setSavedEntryIdx] = useState(0);
|
|
1278
|
+
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
|
1279
|
+
const [focusedFieldIdx, setFocusedFieldIdx] = useState(0);
|
|
1280
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
1281
|
+
|
|
1282
|
+
const entries = useMemo(() => buildEntries(query, workflows), [query, workflows]);
|
|
1283
|
+
const rows = useMemo(() => buildRows(entries, query), [entries, query]);
|
|
1284
|
+
|
|
1285
|
+
// Clamp index when the list shrinks (e.g. typing filters entries out).
|
|
1286
|
+
// Derived during render — keyboard handlers read the clamped value via
|
|
1287
|
+
// refs (useLatest) so no sync-back effect is needed.
|
|
1288
|
+
const clampedEntryIdx = Math.min(entryIdx, Math.max(0, entries.length - 1));
|
|
1289
|
+
|
|
1290
|
+
const focusedWf = entries[clampedEntryIdx]?.workflow;
|
|
1291
|
+
|
|
1292
|
+
const currentFields = useMemo<readonly WorkflowInput[]>(
|
|
1293
|
+
() => focusedWf && focusedWf.inputs.length > 0
|
|
1294
|
+
? focusedWf.inputs
|
|
1295
|
+
: DEFAULT_FIELDS,
|
|
1296
|
+
[focusedWf],
|
|
1297
|
+
);
|
|
1298
|
+
const currentField = currentFields[focusedFieldIdx];
|
|
1299
|
+
|
|
1300
|
+
const invalidFieldIndices = useMemo(() => {
|
|
1301
|
+
const out: number[] = [];
|
|
1302
|
+
for (let i = 0; i < currentFields.length; i++) {
|
|
1303
|
+
const f = currentFields[i];
|
|
1304
|
+
if (!f) continue;
|
|
1305
|
+
const v = fieldValues[f.name] ?? "";
|
|
1306
|
+
if (!isFieldValid(f, v)) out.push(i);
|
|
1307
|
+
}
|
|
1308
|
+
return out;
|
|
1309
|
+
}, [currentFields, fieldValues]);
|
|
1310
|
+
const isFormValid = invalidFieldIndices.length === 0;
|
|
1311
|
+
|
|
1312
|
+
// Textarea change callback ref — useLatest keeps .current in sync
|
|
1313
|
+
// each render so the textarea effect doesn't need to re-attach.
|
|
1314
|
+
const textChangeRef = useLatest(
|
|
1315
|
+
currentField
|
|
1316
|
+
? (text: string) => {
|
|
1317
|
+
setFieldValues((prev) => ({ ...prev, [currentField.name]: text }));
|
|
1318
|
+
}
|
|
1319
|
+
: null,
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
// Stable callback for field input — the setter is referentially stable.
|
|
1323
|
+
const onFieldInput = useCallback(
|
|
1324
|
+
(name: string, v: string) => setFieldValues((prev) => ({ ...prev, [name]: v })),
|
|
1325
|
+
[],
|
|
1326
|
+
);
|
|
1234
1327
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1328
|
+
usePickerKeyboard({
|
|
1329
|
+
entries,
|
|
1330
|
+
clampedEntryIdx,
|
|
1331
|
+
savedEntryIdx,
|
|
1332
|
+
focusedWf,
|
|
1333
|
+
fieldValues,
|
|
1334
|
+
isFormValid,
|
|
1335
|
+
invalidFieldIndices,
|
|
1336
|
+
currentFields,
|
|
1337
|
+
currentField,
|
|
1338
|
+
phase,
|
|
1339
|
+
confirmOpen,
|
|
1340
|
+
onSubmit,
|
|
1341
|
+
onCancel,
|
|
1342
|
+
setPhase,
|
|
1343
|
+
setEntryIdx,
|
|
1344
|
+
setSavedEntryIdx,
|
|
1345
|
+
setFieldValues,
|
|
1346
|
+
setFocusedFieldIdx,
|
|
1347
|
+
setConfirmOpen,
|
|
1348
|
+
});
|
|
1242
1349
|
|
|
1243
1350
|
const hints = confirmOpen
|
|
1244
|
-
?
|
|
1351
|
+
? CONFIRM_HINTS
|
|
1245
1352
|
: phase === "pick"
|
|
1246
|
-
?
|
|
1247
|
-
:
|
|
1353
|
+
? PICK_HINTS
|
|
1354
|
+
: isFormValid
|
|
1355
|
+
? PROMPT_HINTS_VALID
|
|
1356
|
+
: PROMPT_HINTS_INVALID;
|
|
1248
1357
|
|
|
1249
1358
|
return (
|
|
1250
|
-
<
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1359
|
+
<PickerThemeContext value={theme}>
|
|
1360
|
+
<box
|
|
1361
|
+
position="relative"
|
|
1362
|
+
width="100%"
|
|
1363
|
+
height="100%"
|
|
1364
|
+
flexDirection="column"
|
|
1365
|
+
backgroundColor={theme.background}
|
|
1366
|
+
>
|
|
1367
|
+
<Header
|
|
1368
|
+
phase={phase}
|
|
1369
|
+
confirmOpen={confirmOpen}
|
|
1370
|
+
selectedAgent={agent}
|
|
1371
|
+
scopedCount={workflows.length}
|
|
1372
|
+
/>
|
|
1264
1373
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1374
|
+
{phase === "pick" ? (
|
|
1375
|
+
<box
|
|
1376
|
+
flexGrow={1}
|
|
1377
|
+
flexDirection="row"
|
|
1378
|
+
paddingLeft={2}
|
|
1379
|
+
paddingRight={2}
|
|
1380
|
+
paddingTop={1}
|
|
1381
|
+
>
|
|
1382
|
+
<box width={36} flexDirection="column">
|
|
1383
|
+
<FilterBar query={query} focused={phase === "pick"} onInput={setQuery} />
|
|
1384
|
+
<box height={1} />
|
|
1385
|
+
<WorkflowList
|
|
1386
|
+
rows={rows}
|
|
1387
|
+
focusedEntryIdx={clampedEntryIdx}
|
|
1388
|
+
/>
|
|
1389
|
+
</box>
|
|
1390
|
+
<box width={1} backgroundColor={theme.border} />
|
|
1391
|
+
<box flexGrow={1} flexDirection="column">
|
|
1392
|
+
{focusedWf ? (
|
|
1393
|
+
<Preview wf={focusedWf} />
|
|
1394
|
+
) : (
|
|
1395
|
+
<EmptyPreview query={query} />
|
|
1396
|
+
)}
|
|
1397
|
+
</box>
|
|
1289
1398
|
</box>
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
/>
|
|
1302
|
-
) : null}
|
|
1399
|
+
) : phase === "prompt" && focusedWf ? (
|
|
1400
|
+
<InputPhase
|
|
1401
|
+
workflow={focusedWf}
|
|
1402
|
+
agent={agent}
|
|
1403
|
+
fields={currentFields}
|
|
1404
|
+
values={fieldValues}
|
|
1405
|
+
focusedFieldIdx={confirmOpen ? -1 : focusedFieldIdx}
|
|
1406
|
+
onFieldInput={onFieldInput}
|
|
1407
|
+
onTextChangeRef={textChangeRef}
|
|
1408
|
+
/>
|
|
1409
|
+
) : null}
|
|
1303
1410
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
/>
|
|
1411
|
+
<Statusline
|
|
1412
|
+
phase={phase}
|
|
1413
|
+
confirmOpen={confirmOpen}
|
|
1414
|
+
hints={hints}
|
|
1415
|
+
focusedWf={focusedWf}
|
|
1416
|
+
/>
|
|
1311
1417
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1418
|
+
{confirmOpen && focusedWf ? (
|
|
1419
|
+
<ConfirmModal workflow={focusedWf} agent={agent} />
|
|
1420
|
+
) : null}
|
|
1421
|
+
</box>
|
|
1422
|
+
</PickerThemeContext>
|
|
1316
1423
|
);
|
|
1317
1424
|
}
|
|
1318
1425
|
|
|
@@ -1346,7 +1453,8 @@ export class WorkflowPickerPanel {
|
|
|
1346
1453
|
this.resolveSelection = resolve;
|
|
1347
1454
|
});
|
|
1348
1455
|
|
|
1349
|
-
const
|
|
1456
|
+
const isDark = renderer.themeMode !== "light";
|
|
1457
|
+
const theme = buildPickerTheme(resolveTheme(renderer.themeMode), isDark);
|
|
1350
1458
|
this.root = createRoot(renderer);
|
|
1351
1459
|
this.root.render(
|
|
1352
1460
|
<ErrorBoundary
|
|
@@ -1428,7 +1536,9 @@ export class WorkflowPickerPanel {
|
|
|
1428
1536
|
}
|
|
1429
1537
|
try {
|
|
1430
1538
|
this.renderer.destroy();
|
|
1431
|
-
} catch {
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
console.error("[WorkflowPickerPanel] destroy failed:", err);
|
|
1541
|
+
}
|
|
1432
1542
|
}
|
|
1433
1543
|
|
|
1434
1544
|
private handleSubmit(result: WorkflowPickerResult): void {
|
|
@@ -606,6 +606,11 @@ export class HeadlessClaudeClientWrapper {
|
|
|
606
606
|
* directly instead of tmux pane operations. Implements the same `query()`
|
|
607
607
|
* interface as {@link ClaudeSessionWrapper} so workflow callbacks work
|
|
608
608
|
* identically for headless and interactive stages.
|
|
609
|
+
*
|
|
610
|
+
* The `query()` method accepts the full Agent SDK parameter types —
|
|
611
|
+
* `prompt` can be a plain string or an `AsyncIterable<SDKUserMessage>`
|
|
612
|
+
* for multi-turn streaming, and `options` passes through SDK-level
|
|
613
|
+
* configuration (abort controllers, allowed tools, agents, etc.).
|
|
609
614
|
*/
|
|
610
615
|
export class HeadlessClaudeSessionWrapper {
|
|
611
616
|
readonly paneId = "";
|
|
@@ -615,10 +620,13 @@ export class HeadlessClaudeSessionWrapper {
|
|
|
615
620
|
this.sessionId = sessionId;
|
|
616
621
|
}
|
|
617
622
|
|
|
618
|
-
async query(
|
|
623
|
+
async query(
|
|
624
|
+
prompt: string | AsyncIterable<import("@anthropic-ai/claude-agent-sdk").SDKUserMessage>,
|
|
625
|
+
options?: import("@anthropic-ai/claude-agent-sdk").Options,
|
|
626
|
+
): Promise<ClaudeQueryResult> {
|
|
619
627
|
const { query } = await import("@anthropic-ai/claude-agent-sdk");
|
|
620
628
|
let output = "";
|
|
621
|
-
for await (const msg of query({ prompt })) {
|
|
629
|
+
for await (const msg of query({ prompt, options })) {
|
|
622
630
|
if (msg.type === "result") {
|
|
623
631
|
// SDKResultSuccess has `result: string`, not `output`.
|
|
624
632
|
output = String((msg as Record<string, unknown>).result ?? "");
|