@stigmer/react 0.4.1 → 0.4.3

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.
Files changed (40) hide show
  1. package/composer/ComposerToolbar.d.ts.map +1 -1
  2. package/composer/ComposerToolbar.js +1 -2
  3. package/composer/ComposerToolbar.js.map +1 -1
  4. package/github/useGitHubConnection.d.ts.map +1 -1
  5. package/github/useGitHubConnection.js +2 -4
  6. package/github/useGitHubConnection.js.map +1 -1
  7. package/models/ModelSelector.d.ts +34 -18
  8. package/models/ModelSelector.d.ts.map +1 -1
  9. package/models/ModelSelector.js +96 -47
  10. package/models/ModelSelector.js.map +1 -1
  11. package/models/__tests__/useModelRegistry.test.js +4 -2
  12. package/models/__tests__/useModelRegistry.test.js.map +1 -1
  13. package/models/harness.d.ts +26 -2
  14. package/models/harness.d.ts.map +1 -1
  15. package/models/harness.js +29 -4
  16. package/models/harness.js.map +1 -1
  17. package/models/index.d.ts +5 -5
  18. package/models/index.d.ts.map +1 -1
  19. package/models/index.js +2 -2
  20. package/models/index.js.map +1 -1
  21. package/models/registry.d.ts +49 -2
  22. package/models/registry.d.ts.map +1 -1
  23. package/models/registry.js +45 -3
  24. package/models/registry.js.map +1 -1
  25. package/models/useModelRegistry.d.ts.map +1 -1
  26. package/models/useModelRegistry.js +7 -12
  27. package/models/useModelRegistry.js.map +1 -1
  28. package/package.json +4 -4
  29. package/src/composer/ComposerToolbar.tsx +2 -3
  30. package/src/github/useGitHubConnection.ts +2 -4
  31. package/src/models/ModelSelector.tsx +274 -96
  32. package/src/models/__tests__/useModelRegistry.test.tsx +4 -2
  33. package/src/models/harness.ts +51 -5
  34. package/src/models/index.ts +5 -5
  35. package/src/models/registry.ts +96 -3
  36. package/src/models/useModelRegistry.ts +6 -8
  37. package/src/workspace/WorkspaceEditor.tsx +37 -11
  38. package/styles.css +1 -1
  39. package/workspace/WorkspaceEditor.js +18 -4
  40. package/workspace/WorkspaceEditor.js.map +1 -1
@@ -4,8 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { Popover } from "@base-ui/react/popover";
5
5
  import { cn } from "@stigmer/theme";
6
6
  import { useModelRegistry } from "./useModelRegistry";
7
- import { modelKey, parseModelKey, type ModelInfo, type CostTier } from "./registry";
8
- import { HARNESS_LABELS, type HarnessOption } from "./harness";
7
+ import type { ModelInfo, CostTier, SpeedTier } from "./registry";
8
+ import { HARNESS_META, HARNESS_OPTIONS, type HarnessOption } from "./harness";
9
9
 
10
10
  const COST_TIER_LABEL: Record<CostTier, string> = {
11
11
  economy: "$",
@@ -13,46 +13,70 @@ const COST_TIER_LABEL: Record<CostTier, string> = {
13
13
  premium: "$$$",
14
14
  };
15
15
 
16
+ const SPEED_TIER_LABEL: Record<SpeedTier, string> = {
17
+ fastest: "Fastest",
18
+ fast: "Fast",
19
+ balanced: "Balanced",
20
+ slow: "Powerful",
21
+ };
22
+
16
23
  /** Props for {@link ModelSelector}. */
17
24
  export interface ModelSelectorProps {
18
- /** Currently selected compound key (`"native/claude-sonnet-4.6"`) or plain `modelId`. */
25
+ /** Currently selected model ID. */
19
26
  readonly value?: string;
20
27
  /** Called when the user picks a different model. Receives the `modelId`. */
21
28
  readonly onValueChange: (modelId: string) => void;
22
29
  /**
23
- * When provided, restricts the catalog to a single harness (backward compat).
24
- * When omitted, shows the unified picker with models from both engines.
30
+ * Current harness. When provided as a single value, locks the selector
31
+ * to that harness (dropdown hidden). When omitted, shows the harness dropdown.
25
32
  */
26
33
  readonly harness?: HarnessOption;
34
+ /** Called when user changes harness in the dropdown. */
35
+ readonly onHarnessChange?: (harness: HarnessOption) => void;
27
36
  /**
28
- * Fires when the selected model belongs to a different harness than
29
- * the previous selection. Only relevant in unified mode (no `harness` prop).
37
+ * Restrict which harnesses appear in the dropdown.
38
+ * When omitted, shows all registered harnesses that have models in the registry.
30
39
  */
31
- readonly onHarnessResolved?: (harness: HarnessOption) => void;
40
+ readonly availableHarnesses?: readonly HarnessOption[];
41
+ /** Override the curated (featured) list for the current harness. */
42
+ readonly curatedModels?: readonly string[];
43
+ /** Grouping in the "Show All" expanded view. Default: "provider". */
44
+ readonly groupBy?: "provider" | "tier" | "none";
45
+ /** Show speed tier badge. Default: true. */
46
+ readonly showSpeedBadge?: boolean;
47
+ /** Show short descriptions in curated view. Default: true. */
48
+ readonly showDescriptions?: boolean;
49
+ /** Compact mode: smaller trigger, no descriptions. Default: false. */
50
+ readonly compact?: boolean;
32
51
  /** Additional CSS class names for the trigger button. */
33
52
  readonly className?: string;
34
53
  /** When true, disables the selector. */
35
54
  readonly disabled?: boolean;
55
+
56
+ /**
57
+ * @deprecated Use {@link onHarnessChange} instead.
58
+ */
59
+ readonly onHarnessResolved?: (harness: HarnessOption) => void;
36
60
  }
37
61
 
38
62
  /**
39
- * Cursor-style model picker: a flat searchable list inside a popover.
40
- *
41
- * Shows a curated list of featured models by default. The user can
42
- * expand via "Show All Models" or type to search the full catalog.
63
+ * Combined harness + model picker with a compact trigger button.
43
64
  *
44
- * Each model row shows the display name, an engine tag
45
- * ("Stigmer" / "Cursor"), and a cost-tier indicator.
65
+ * Shows a harness dropdown at the top of the popover (when not locked
66
+ * to a single harness), followed by a curated model list scoped to
67
+ * the selected harness. Supports search and progressive disclosure
68
+ * via "Show All Models."
46
69
  *
47
- * In unified mode (no `harness` prop), selecting a model implicitly
48
- * resolves the harness via {@link ModelSelectorProps.onHarnessResolved}.
70
+ * The trigger button displays the current selection in compact format:
71
+ * `Harness · Model Name ▾` (or just `Model Name ▾` when harness is locked).
49
72
  *
50
73
  * @example
51
74
  * ```tsx
52
75
  * <ModelSelector
53
- * value={selectedModelId}
54
- * onValueChange={setSelectedModelId}
55
- * onHarnessResolved={setHarness}
76
+ * value={modelId}
77
+ * onValueChange={setModelId}
78
+ * harness={harness}
79
+ * onHarnessChange={setHarness}
56
80
  * />
57
81
  * ```
58
82
  */
@@ -60,16 +84,27 @@ export function ModelSelector({
60
84
  value,
61
85
  onValueChange,
62
86
  harness,
87
+ onHarnessChange,
63
88
  onHarnessResolved,
89
+ availableHarnesses,
90
+ curatedModels,
91
+ groupBy = "provider",
92
+ showSpeedBadge = true,
93
+ showDescriptions = true,
94
+ compact = false,
64
95
  className,
65
96
  disabled,
66
97
  }: ModelSelectorProps) {
67
- const isUnified = harness === undefined;
68
- const { models, featured, defaultModel, getModel, getByKey } = useModelRegistry(
69
- isUnified ? undefined : { harness },
98
+ const isHarnessLocked = harness !== undefined;
99
+ const [internalHarness, setInternalHarness] = useState<HarnessOption>(harness ?? "native");
100
+ const activeHarness = harness ?? internalHarness;
101
+
102
+ const { models, featured, defaultModel, getModel, byProvider } = useModelRegistry(
103
+ { harness: activeHarness },
70
104
  );
71
105
 
72
106
  const [open, setOpen] = useState(false);
107
+ const [harnessOpen, setHarnessOpen] = useState(false);
73
108
  const [searchQuery, setSearchQuery] = useState("");
74
109
  const [showAll, setShowAll] = useState(false);
75
110
  const [highlightIdx, setHighlightIdx] = useState(-1);
@@ -77,31 +112,56 @@ export function ModelSelector({
77
112
  const searchRef = useRef<HTMLInputElement>(null);
78
113
  const listRef = useRef<HTMLDivElement>(null);
79
114
 
80
- const resolveSelected = useCallback((): ModelInfo | undefined => {
81
- if (!value) return undefined;
82
- if (isUnified) {
83
- const byKey = getByKey(value);
84
- if (byKey) return byKey;
85
- }
86
- return getModel(value);
87
- }, [value, isUnified, getByKey, getModel]);
115
+ const resolvedHarnesses = useMemo(() => {
116
+ if (availableHarnesses) return availableHarnesses;
117
+ // For now, show native and cursor (the two harnesses with models in the registry).
118
+ // Future harnesses will be added to the registry and appear here automatically.
119
+ return HARNESS_OPTIONS.filter((h) => h === "native" || h === "cursor");
120
+ }, [availableHarnesses]);
88
121
 
89
- const selectedModel = resolveSelected() ?? defaultModel;
122
+ const selectedModel = (value ? getModel(value) : undefined) ?? defaultModel;
90
123
 
91
124
  const isSearching = searchQuery.length > 0;
92
125
  const lowerQuery = searchQuery.toLowerCase();
93
126
 
127
+ const curatedSet = useMemo(() => {
128
+ if (curatedModels) return new Set(curatedModels);
129
+ return null;
130
+ }, [curatedModels]);
131
+
132
+ const featuredModels = useMemo(() => {
133
+ if (curatedSet) {
134
+ return models.filter((m) => curatedSet.has(m.modelId));
135
+ }
136
+ return featured;
137
+ }, [models, featured, curatedSet]);
138
+
94
139
  const visibleModels: readonly ModelInfo[] = useMemo(() => {
95
140
  if (isSearching) {
96
141
  return models.filter((m) =>
97
142
  m.displayName.toLowerCase().includes(lowerQuery)
98
143
  || m.modelId.toLowerCase().includes(lowerQuery)
99
- || HARNESS_LABELS[m.harness].toLowerCase().includes(lowerQuery),
144
+ || m.shortDescription.toLowerCase().includes(lowerQuery),
100
145
  );
101
146
  }
102
147
  if (showAll) return models;
103
- return featured.length > 0 ? featured : models;
104
- }, [models, featured, isSearching, showAll, lowerQuery]);
148
+ return featuredModels.length > 0 ? featuredModels : models;
149
+ }, [models, featuredModels, isSearching, showAll, lowerQuery]);
150
+
151
+ const groupedModels = useMemo(() => {
152
+ if (!showAll || groupBy === "none" || isSearching) return null;
153
+ const groups = new Map<string, ModelInfo[]>();
154
+ for (const model of models) {
155
+ const key = groupBy === "provider" ? model.provider : model.costTier;
156
+ const group = groups.get(key);
157
+ if (group) {
158
+ group.push(model);
159
+ } else {
160
+ groups.set(key, [model]);
161
+ }
162
+ }
163
+ return groups;
164
+ }, [models, showAll, groupBy, isSearching]);
105
165
 
106
166
  useEffect(() => {
107
167
  setHighlightIdx(-1);
@@ -112,6 +172,7 @@ export function ModelSelector({
112
172
  setSearchQuery("");
113
173
  setShowAll(false);
114
174
  setHighlightIdx(-1);
175
+ setHarnessOpen(false);
115
176
  }
116
177
  }, [open]);
117
178
 
@@ -121,16 +182,23 @@ export function ModelSelector({
121
182
  }
122
183
  }, [open]);
123
184
 
185
+ const handleHarnessChange = useCallback(
186
+ (newHarness: HarnessOption) => {
187
+ setInternalHarness(newHarness);
188
+ onHarnessChange?.(newHarness);
189
+ onHarnessResolved?.(newHarness);
190
+ setShowAll(false);
191
+ setSearchQuery("");
192
+ },
193
+ [onHarnessChange, onHarnessResolved],
194
+ );
195
+
124
196
  const selectModel = useCallback(
125
197
  (model: ModelInfo) => {
126
- const key = isUnified ? modelKey(model.harness, model.modelId) : model.modelId;
127
- onValueChange(key);
128
- if (isUnified && onHarnessResolved && model.harness !== selectedModel?.harness) {
129
- onHarnessResolved(model.harness);
130
- }
198
+ onValueChange(model.modelId);
131
199
  setOpen(false);
132
200
  },
133
- [isUnified, onValueChange, onHarnessResolved, selectedModel],
201
+ [onValueChange],
134
202
  );
135
203
 
136
204
  const scrollHighlightIntoView = useCallback((idx: number) => {
@@ -175,11 +243,10 @@ export function ModelSelector({
175
243
  [visibleModels, highlightIdx, selectModel, scrollHighlightIntoView],
176
244
  );
177
245
 
178
- const showShowAllButton = !isSearching && !showAll && featured.length > 0 && featured.length < models.length;
246
+ const showShowAllButton = !isSearching && !showAll && featuredModels.length > 0 && featuredModels.length < models.length;
179
247
 
180
248
  const triggerLabel = selectedModel.displayName;
181
- const triggerHarness = isUnified ? HARNESS_LABELS[selectedModel.harness] : undefined;
182
- const triggerCost = COST_TIER_LABEL[selectedModel.costTier];
249
+ const triggerHarness = !isHarnessLocked ? HARNESS_META[activeHarness].label : undefined;
183
250
 
184
251
  return (
185
252
  <Popover.Root open={open} onOpenChange={setOpen}>
@@ -190,17 +257,17 @@ export function ModelSelector({
190
257
  "bg-background px-2.5 py-1.5 text-xs text-foreground",
191
258
  "hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
192
259
  "disabled:pointer-events-none disabled:opacity-50",
193
- "transition-colors max-w-[18rem] max-sm:max-w-[10rem]",
260
+ "transition-colors max-w-[20rem] max-sm:max-w-[12rem]",
194
261
  className,
195
262
  )}
196
263
  >
197
- <span className="truncate">{triggerLabel}</span>
198
264
  {triggerHarness && (
199
- <span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[0.6rem] font-medium text-muted-foreground">
200
- {triggerHarness}
201
- </span>
265
+ <span className="shrink-0 text-muted-foreground">{triggerHarness}</span>
266
+ )}
267
+ {triggerHarness && (
268
+ <span className="shrink-0 text-border" aria-hidden>·</span>
202
269
  )}
203
- <span className="shrink-0 text-[0.6rem] text-muted-foreground">{triggerCost}</span>
270
+ <span className="truncate">{triggerLabel}</span>
204
271
  <ChevronIcon />
205
272
  </Popover.Trigger>
206
273
 
@@ -214,8 +281,76 @@ export function ModelSelector({
214
281
  "text-popover-foreground",
215
282
  )}
216
283
  >
284
+ {/* Harness selector — inline label + compact dropdown; disabled when locked */}
285
+ <div className="relative flex items-center justify-between border-b border-border px-3 py-2">
286
+ <span className="text-xs text-muted-foreground">Harness</span>
287
+ <button
288
+ type="button"
289
+ aria-haspopup="listbox"
290
+ aria-expanded={harnessOpen}
291
+ aria-label="Select harness"
292
+ disabled={isHarnessLocked}
293
+ className={cn(
294
+ "inline-flex items-center gap-1.5 rounded-md border border-border",
295
+ "bg-background px-2.5 py-1.5 text-xs text-foreground",
296
+ "transition-colors",
297
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
298
+ isHarnessLocked
299
+ ? "cursor-not-allowed opacity-50"
300
+ : "hover:bg-accent-hover",
301
+ )}
302
+ onClick={() => {
303
+ if (!isHarnessLocked) setHarnessOpen(!harnessOpen);
304
+ }}
305
+ onKeyDown={(e) => {
306
+ if (e.key === "Escape" && harnessOpen) {
307
+ e.stopPropagation();
308
+ setHarnessOpen(false);
309
+ }
310
+ }}
311
+ >
312
+ <span>{HARNESS_META[activeHarness].label}</span>
313
+ {!isHarnessLocked && <ChevronIcon />}
314
+ </button>
315
+
316
+ {!isHarnessLocked && harnessOpen && (
317
+ <div
318
+ role="listbox"
319
+ aria-label="Available harnesses"
320
+ className={cn(
321
+ "absolute right-3 top-full z-10 mt-1 overflow-hidden rounded-md border border-border",
322
+ "bg-popover shadow-md",
323
+ )}
324
+ >
325
+ {resolvedHarnesses.map((h) => {
326
+ const isActive = h === activeHarness;
327
+ return (
328
+ <button
329
+ key={h}
330
+ type="button"
331
+ role="option"
332
+ aria-selected={isActive}
333
+ className={cn(
334
+ "flex w-full items-center gap-2 px-2.5 py-1.5 text-xs transition-colors",
335
+ "hover:bg-accent-hover",
336
+ isActive && "font-medium",
337
+ )}
338
+ onClick={() => {
339
+ handleHarnessChange(h);
340
+ setHarnessOpen(false);
341
+ }}
342
+ >
343
+ <span className="flex-1 text-left">{HARNESS_META[h].label}</span>
344
+ {isActive && <CheckIcon className="shrink-0 text-primary" />}
345
+ </button>
346
+ );
347
+ })}
348
+ </div>
349
+ )}
350
+ </div>
351
+
217
352
  {/* Search input */}
218
- <div className="border-b border-border px-2 py-1.5">
353
+ <div className="border-b border-border px-3 py-1.5">
219
354
  <input
220
355
  ref={searchRef}
221
356
  role="searchbox"
@@ -236,59 +371,47 @@ export function ModelSelector({
236
371
  ref={listRef}
237
372
  role="listbox"
238
373
  aria-label="Available models"
239
- className="max-h-64 overflow-y-auto p-1"
374
+ className="max-h-72 overflow-y-auto p-1"
240
375
  >
241
376
  {visibleModels.length === 0 && (
242
377
  <div className="px-2 py-3 text-center text-xs text-muted-foreground">
243
378
  No models found
244
379
  </div>
245
380
  )}
246
- {visibleModels.map((model, idx) => {
247
- const key = modelKey(model.harness, model.modelId);
248
- const isSelected = selectedModel
249
- ? model.harness === selectedModel.harness && model.modelId === selectedModel.modelId
250
- : false;
251
- const isHighlighted = idx === highlightIdx;
252
-
253
- return (
254
- <button
255
- key={key}
256
- data-model-option=""
257
- role="option"
258
- aria-selected={isSelected}
259
- type="button"
260
- className={cn(
261
- "flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none",
262
- "transition-colors",
263
- isHighlighted && "bg-accent text-accent-foreground",
264
- !isHighlighted && "hover:bg-accent-hover",
265
- isSelected && "font-medium",
266
- )}
381
+
382
+ {/* Grouped rendering */}
383
+ {groupedModels ? (
384
+ Array.from(groupedModels.entries()).map(([group, groupModels]) => (
385
+ <div key={group}>
386
+ <div className="px-2 pb-0.5 pt-2 text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground">
387
+ {group}
388
+ </div>
389
+ {groupModels.map((model) => (
390
+ <ModelRow
391
+ key={model.modelId}
392
+ model={model}
393
+ isSelected={model.modelId === selectedModel.modelId}
394
+ showDescription={false}
395
+ showSpeedBadge={showSpeedBadge}
396
+ onClick={() => selectModel(model)}
397
+ />
398
+ ))}
399
+ </div>
400
+ ))
401
+ ) : (
402
+ visibleModels.map((model, idx) => (
403
+ <ModelRow
404
+ key={model.modelId}
405
+ model={model}
406
+ isSelected={model.modelId === selectedModel.modelId}
407
+ isHighlighted={idx === highlightIdx}
408
+ showDescription={showDescriptions && !compact && !isSearching && !showAll}
409
+ showSpeedBadge={showSpeedBadge}
267
410
  onClick={() => selectModel(model)}
268
411
  onMouseEnter={() => setHighlightIdx(idx)}
269
- >
270
- {/* Model name */}
271
- <span className="flex-1 truncate text-left">{model.displayName}</span>
272
-
273
- {/* Engine tag (unified mode only) */}
274
- {isUnified && (
275
- <span className="shrink-0 rounded bg-muted px-1 py-0.5 text-[0.55rem] font-medium text-muted-foreground">
276
- {HARNESS_LABELS[model.harness]}
277
- </span>
278
- )}
279
-
280
- {/* Cost tier */}
281
- <span className="shrink-0 text-[0.6rem] text-muted-foreground">
282
- {COST_TIER_LABEL[model.costTier]}
283
- </span>
284
-
285
- {/* Selected checkmark */}
286
- {isSelected && (
287
- <CheckIcon className="shrink-0 text-primary" />
288
- )}
289
- </button>
290
- );
291
- })}
412
+ />
413
+ ))
414
+ )}
292
415
 
293
416
  {/* Show All Models */}
294
417
  {showShowAllButton && (
@@ -301,7 +424,7 @@ export function ModelSelector({
301
424
  )}
302
425
  onClick={() => setShowAll(true)}
303
426
  >
304
- Show All Models
427
+ Show all models
305
428
  </button>
306
429
  )}
307
430
  </div>
@@ -312,6 +435,61 @@ export function ModelSelector({
312
435
  );
313
436
  }
314
437
 
438
+ interface ModelRowProps {
439
+ model: ModelInfo;
440
+ isSelected: boolean;
441
+ isHighlighted?: boolean;
442
+ showDescription: boolean;
443
+ showSpeedBadge: boolean;
444
+ onClick: () => void;
445
+ onMouseEnter?: () => void;
446
+ }
447
+
448
+ function ModelRow({
449
+ model,
450
+ isSelected,
451
+ isHighlighted,
452
+ showDescription,
453
+ showSpeedBadge,
454
+ onClick,
455
+ onMouseEnter,
456
+ }: ModelRowProps) {
457
+ return (
458
+ <button
459
+ data-model-option=""
460
+ role="option"
461
+ aria-selected={isSelected}
462
+ type="button"
463
+ className={cn(
464
+ "flex w-full cursor-pointer flex-col rounded-md px-2 py-1.5 text-xs outline-none",
465
+ "transition-colors",
466
+ isHighlighted && "bg-accent text-accent-foreground",
467
+ !isHighlighted && "hover:bg-accent-hover",
468
+ )}
469
+ onClick={onClick}
470
+ onMouseEnter={onMouseEnter}
471
+ >
472
+ <div className="flex w-full items-center gap-2">
473
+ <span className="flex-1 truncate text-left font-medium">{model.displayName}</span>
474
+
475
+ <span className="shrink-0 text-[0.6rem] text-muted-foreground">
476
+ {showSpeedBadge
477
+ ? `${SPEED_TIER_LABEL[model.speedTier]} ${COST_TIER_LABEL[model.costTier]}`
478
+ : COST_TIER_LABEL[model.costTier]}
479
+ </span>
480
+
481
+ {isSelected && <CheckIcon className="shrink-0 text-primary" />}
482
+ </div>
483
+
484
+ {showDescription && model.shortDescription && (
485
+ <span className="mt-0.5 block text-left text-[0.65rem] text-muted-foreground">
486
+ {model.shortDescription}
487
+ </span>
488
+ )}
489
+ </button>
490
+ );
491
+ }
492
+
315
493
  function ChevronIcon() {
316
494
  return (
317
495
  <svg
@@ -137,11 +137,13 @@ describe("useModelRegistry", () => {
137
137
  expect(cursorModels).toHaveLength(0);
138
138
  });
139
139
 
140
- it("resolves defaultModel to DEFAULT_MODEL_ID", () => {
140
+ it("resolves defaultModel to the first featured native model", () => {
141
141
  const { result } = renderHook(() =>
142
142
  useModelRegistry({ harness: "native" }),
143
143
  );
144
- expect(result.current.defaultModel.modelId).toBe(DEFAULT_MODEL_ID);
144
+ const featured = result.current.featured;
145
+ expect(featured.length).toBeGreaterThan(0);
146
+ expect(result.current.defaultModel.modelId).toBe(featured[0].modelId);
145
147
  });
146
148
  });
147
149
 
@@ -5,15 +5,61 @@ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
5
5
  *
6
6
  * Used on component props and hook options so platform builders
7
7
  * do not need to import proto enums to use the SDK.
8
+ *
9
+ * This union grows as new execution engines are integrated.
10
+ * Each value must have a corresponding entry in {@link HARNESS_META}.
8
11
  */
9
- export type HarnessOption = "native" | "cursor";
12
+ export type HarnessOption =
13
+ | "native"
14
+ | "cursor"
15
+ | "copilot"
16
+ | "claude_code"
17
+ | "codex"
18
+ | "devin";
19
+
20
+ /** Display metadata for a single harness. */
21
+ export interface HarnessDisplayInfo {
22
+ /** User-facing label shown in the harness dropdown. */
23
+ readonly label: string;
24
+ /** One-line description shown as a tooltip or subtitle. */
25
+ readonly description: string;
26
+ }
10
27
 
11
- /** User-facing labels for each harness option. */
12
- export const HARNESS_LABELS: Readonly<Record<HarnessOption, string>> = {
13
- native: "Stigmer",
14
- cursor: "Cursor",
28
+ /**
29
+ * Display metadata for all registered harnesses.
30
+ *
31
+ * Drives the harness dropdown in {@link ModelSelector} and provides
32
+ * labels for the compact trigger button.
33
+ */
34
+ export const HARNESS_META: Readonly<Record<HarnessOption, HarnessDisplayInfo>> = {
35
+ native: { label: "Stigmer", description: "Stigmer's native agent runtime" },
36
+ cursor: { label: "Cursor", description: "Cursor IDE agent with codebase indexing" },
37
+ copilot: { label: "GitHub Copilot", description: "GitHub-native sub-agent orchestration" },
38
+ claude_code: { label: "Claude Agent SDK", description: "Anthropic's agent SDK with built-in tools" },
39
+ codex: { label: "OpenAI Codex", description: "Thread-based execution with structured output" },
40
+ devin: { label: "Devin", description: "Full autonomous engineer, session-based" },
15
41
  };
16
42
 
43
+ /**
44
+ * User-facing labels for each harness option.
45
+ *
46
+ * @deprecated Use {@link HARNESS_META} instead for full display metadata.
47
+ * Kept for backward compatibility with existing consumers.
48
+ */
49
+ export const HARNESS_LABELS: Readonly<Record<HarnessOption, string>> = Object.fromEntries(
50
+ Object.entries(HARNESS_META).map(([k, v]) => [k, v.label]),
51
+ ) as Record<HarnessOption, string>;
52
+
53
+ /** Ordered list of all registered harness IDs. */
54
+ export const HARNESS_OPTIONS: readonly HarnessOption[] = [
55
+ "native",
56
+ "cursor",
57
+ "copilot",
58
+ "claude_code",
59
+ "codex",
60
+ "devin",
61
+ ];
62
+
17
63
  /** Platform default — resolves to the native engine. */
18
64
  export const DEFAULT_HARNESS: HarnessOption = "native";
19
65
 
@@ -1,11 +1,11 @@
1
- export { MODEL_REGISTRY, DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID, DISABLED_PROVIDERS, modelKey, parseModelKey } from "./registry";
2
- export type { ParsedModelKey } from "./registry";
3
- export type { ModelInfo, Provider, CostTier } from "./registry";
1
+ export { MODEL_REGISTRY, DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID, DISABLED_PROVIDERS, modelKey, parseModelKey, resolveDefaultModelId } from "./registry";
2
+ export type { ParsedModelKey, DefaultModelResolution, DefaultModelSource } from "./registry";
3
+ export type { ModelInfo, Provider, CostTier, SpeedTier } from "./registry";
4
4
  export { useModelRegistry } from "./useModelRegistry";
5
5
  export type { UseModelRegistryReturn, UseModelRegistryOptions } from "./useModelRegistry";
6
6
  export { ModelSelector } from "./ModelSelector";
7
7
  export type { ModelSelectorProps } from "./ModelSelector";
8
8
  export { HarnessSelector } from "./HarnessSelector";
9
9
  export type { HarnessSelectorProps } from "./HarnessSelector";
10
- export { DEFAULT_HARNESS, HARNESS_LABELS, toProtoHarness, fromProtoHarness } from "./harness";
11
- export type { HarnessOption } from "./harness";
10
+ export { DEFAULT_HARNESS, HARNESS_LABELS, HARNESS_META, HARNESS_OPTIONS, toProtoHarness, fromProtoHarness } from "./harness";
11
+ export type { HarnessOption, HarnessDisplayInfo } from "./harness";