@adecore/agents-react 0.29.3 → 0.30.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.
@@ -1,4 +1,5 @@
1
- import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
2
+ export declare const COMPACT_MODEL_LIMIT = 8;
2
3
  export type PickerEntry = {
3
4
  kind: 'model';
4
5
  provider: ProviderInfo;
@@ -6,5 +7,14 @@ export type PickerEntry = {
6
7
  } | {
7
8
  kind: 'legacy';
8
9
  count: number;
10
+ } | {
11
+ kind: 'all';
12
+ provider: ProviderInfo;
13
+ count: number;
14
+ open: boolean;
9
15
  };
10
- export declare function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean): PickerEntry[];
16
+ export interface PickerCurrent {
17
+ provider: AgentKind;
18
+ model: string;
19
+ }
20
+ export declare function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean, expanded?: ReadonlySet<AgentKind>, current?: PickerCurrent | null): PickerEntry[];
@@ -1,17 +1,51 @@
1
+ /*
2
+ * A provider with more live models than this keeps the list short: the model that is picked and the default,
3
+ * with the rest behind one row that opens all of them. A list of three hundred models is not one to scroll.
4
+ */
5
+ export const COMPACT_MODEL_LIMIT = 8;
1
6
  function matches(model, query) {
2
- return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
7
+ return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query) || (model.vendor?.toLowerCase().includes(query) ?? false);
8
+ }
9
+ /*
10
+ * By maker, then by name, when the provider says who made its models; a list with no makers keeps the order
11
+ * it came in. A model with no maker goes last, so the ones that have one read as groups.
12
+ */
13
+ function byVendor(models) {
14
+ if (!models.some((model) => model.vendor !== undefined)) {
15
+ return models;
16
+ }
17
+ return [...models].sort((a, b) => {
18
+ if (a.vendor === undefined || b.vendor === undefined) {
19
+ return a.vendor === b.vendor ? 0 : a.vendor === undefined ? 1 : -1;
20
+ }
21
+ return (a.vendor.localeCompare(b.vendor, undefined, { sensitivity: 'base' }) ||
22
+ a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }));
23
+ });
3
24
  }
25
+ const NONE = new Set();
4
26
  /*
5
27
  * The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
6
- * only that model still finds the row that explains why it cannot be picked.
28
+ * only that model still finds the row that explains why it cannot be picked. A search looks through
29
+ * every model of every provider; without one, a provider with many models shows a short list and a row
30
+ * that opens the rest, grouped by maker.
7
31
  */
8
- export function pickerEntriesOf(providers, query, legacyOpen) {
9
- const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model', provider: entry, model })));
32
+ export function pickerEntriesOf(providers, query, legacyOpen, expanded = NONE, current = null) {
33
+ const rowsOf = (entry, models) => byVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
10
34
  if (query !== '') {
11
- return rows.filter((row) => matches(row.model, query));
35
+ return providers.flatMap((entry) => rowsOf(entry, entry.models.filter((model) => matches(model, query))));
12
36
  }
13
- const live = rows.filter((row) => !row.model.legacy);
14
- const legacy = rows.filter((row) => row.model.legacy);
37
+ const legacy = providers.flatMap((entry) => entry.models.filter((model) => model.legacy).map((model) => ({ kind: 'model', provider: entry, model })));
38
+ const live = providers.flatMap((entry) => {
39
+ const models = entry.models.filter((model) => !model.legacy);
40
+ if (models.length <= COMPACT_MODEL_LIMIT) {
41
+ return rowsOf(entry, models);
42
+ }
43
+ if (expanded.has(entry.kind)) {
44
+ return [{ kind: 'all', provider: entry, count: models.length, open: true }, ...rowsOf(entry, models)];
45
+ }
46
+ const shortlist = models.filter((model) => model.isDefault || (current?.provider === entry.kind && current.model === model.slug));
47
+ return [...rowsOf(entry, shortlist), { kind: 'all', provider: entry, count: models.length, open: false }];
48
+ });
15
49
  if (legacy.length === 0) {
16
50
  return live;
17
51
  }
@@ -24,6 +24,7 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
24
24
  const { t } = useTranslation('agent-chat');
25
25
  const [query, setQuery] = useState('');
26
26
  const [legacyOpen, setLegacyOpen] = useState(false);
27
+ const [expanded, setExpanded] = useState(new Set());
27
28
  const [index, setIndex] = useState(0);
28
29
  const inputRef = useRef(null);
29
30
  const listRef = useRef(null);
@@ -32,7 +33,7 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
32
33
  const chip = agentChipOf(providers, provider, selection.model);
33
34
  const grouped = providers.length > 1;
34
35
  const trimmed = query.trim().toLowerCase();
35
- const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
36
+ const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen, expanded, { provider, model: selection.model }), [providers, trimmed, legacyOpen, expanded, provider, selection.model]);
36
37
  const scope = useContext(ChatScopeContext);
37
38
  const machine = host.useMachineLabel(scope?.id ?? '');
38
39
  const reported = useReportedModels(provider);
@@ -44,6 +45,7 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
44
45
  if (!next) {
45
46
  setQuery('');
46
47
  setLegacyOpen(false);
48
+ setExpanded(new Set());
47
49
  }
48
50
  setIndex(0);
49
51
  onOpenChange(next);
@@ -61,6 +63,18 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
61
63
  inputRef.current?.focus();
62
64
  return;
63
65
  }
66
+ if (entry.kind === 'all') {
67
+ const kind = entry.provider.kind;
68
+ setExpanded((now) => {
69
+ const next = new Set(now);
70
+ if (!next.delete(kind)) {
71
+ next.add(kind);
72
+ }
73
+ return next;
74
+ });
75
+ inputRef.current?.focus();
76
+ return;
77
+ }
64
78
  if (requirementOf(entry) !== null) {
65
79
  return;
66
80
  }
@@ -85,16 +99,30 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
85
99
  setQuery(e.target.value);
86
100
  setIndex(0);
87
101
  }, onKeyDown: onKeyDown })] }), _jsxs("div", { ref: listRef, className: "max-h-72 overflow-auto p-1", role: "listbox", children: [entries.length === 0 && (_jsx("div", { className: "px-3 py-6 text-center text-xs text-text-faint", children: providers.length === 0 ? t('pickers.model.noProvider') : t('pickers.model.noMatch') })), entries.map((entry, i) => {
102
+ if (entry.kind === 'all') {
103
+ const before = entries[i - 1];
104
+ const header = grouped && (before === undefined || before.kind === 'legacy' || before.provider.kind !== entry.provider.kind);
105
+ return (_jsxs("div", { children: [header && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), _jsxs("button", { "data-active": i === index, "aria-expanded": entry.open, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted", onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsx(Icon, { icon: entry.open ? ChevronDown : ChevronRight, size: 12 }), t('pickers.model.allGroup', { count: entry.count })] })] }, `all-${entry.provider.kind}`));
106
+ }
88
107
  if (entry.kind === 'legacy') {
89
108
  return (_jsxs("button", { "data-active": i === index, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs text-text-faint", onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsx(Icon, { icon: legacyOpen ? ChevronDown : ChevronRight, size: 12 }), t('pickers.model.legacyGroup', { count: entry.count })] }, "legacy"));
90
109
  }
91
110
  const previous = entries[i - 1];
92
- const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
111
+ // A provider's rows start where the one before it ended; its row that opens the rest belongs to it.
112
+ const first = grouped && (previous === undefined || previous.kind === 'legacy' || previous.provider.kind !== entry.provider.kind);
113
+ // The makers read as groups only where the whole list is shown, so a short list is not cut by labels.
114
+ const vendorOpen = trimmed !== '' || expanded.has(entry.provider.kind);
115
+ const vendorFirst = vendorOpen &&
116
+ entry.model.vendor !== undefined &&
117
+ (previous === undefined ||
118
+ previous.kind !== 'model' ||
119
+ previous.provider.kind !== entry.provider.kind ||
120
+ previous.model.vendor !== entry.model.vendor);
93
121
  const requirement = requirementOf(entry);
94
122
  const next = entries[i + 1];
95
123
  const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
96
124
  const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
97
- return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), _jsxs("button", { role: "option", "aria-selected": chosen, "aria-disabled": requirement !== null || undefined, "data-active": i === index, className: `cursor-row flex w-full flex-col items-stretch gap-0.5 rounded-md px-2.5 py-1.5 text-left text-sm ${requirement === null ? 'text-text-muted' : 'text-text-faint'}`, onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx(Menu.Check, { kind: "radio", checked: chosen }), _jsx("span", { className: "min-w-0 truncate", children: entry.model.name }), entry.model.badge && (_jsx(Pill, { tone: "accent", shape: "tag", size: "sm", children: entry.model.badge.charAt(0).toLocaleUpperCase() + entry.model.badge.slice(1) })), _jsx("span", { className: "grow" }), entry.model.legacy && _jsx("span", { className: "text-xs text-text-faint", children: t('pickers.model.legacy') })] }), requirement !== null && (_jsx("span", { className: "pl-6 text-xs text-text-faint", children: machine === null
125
+ return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), vendorFirst && _jsx(Menu.Label, { children: entry.model.vendor }), _jsxs("button", { role: "option", "aria-selected": chosen, "aria-disabled": requirement !== null || undefined, "data-active": i === index, className: `cursor-row flex w-full flex-col items-stretch gap-0.5 rounded-md px-2.5 py-1.5 text-left text-sm ${requirement === null ? 'text-text-muted' : 'text-text-faint'}`, onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx(Menu.Check, { kind: "radio", checked: chosen }), _jsx("span", { className: "min-w-0 truncate", children: entry.model.name }), entry.model.badge && (_jsx(Pill, { tone: "accent", shape: "tag", size: "sm", children: entry.model.badge.charAt(0).toLocaleUpperCase() + entry.model.badge.slice(1) })), _jsx("span", { className: "grow" }), entry.model.legacy && _jsx("span", { className: "text-xs text-text-faint", children: t('pickers.model.legacy') })] }), requirement !== null && (_jsx("span", { className: "pl-6 text-xs text-text-faint", children: machine === null
98
126
  ? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
99
127
  : t('pickers.model.requires', { cli: requirement.cli, version: requirement.minVersion, machine }) }))] }), lastOfGroup && blockedKinds.has(entry.provider.kind) && (_jsx("button", { type: "button", className: "cursor-row flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted", onClick: () => {
100
128
  setOpen(false);
@@ -4,12 +4,13 @@ import { isBlock } from '../../logic/timeline.js';
4
4
  export const FOLLOW_THRESHOLD_PX = 40;
5
5
  /*
6
6
  * The gaps a row wears for where it sits: a question is followed by its answer gap, and a seam where
7
- * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A question
7
+ * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A visual has no
8
+ * margin of its own like prose does, so the row under one takes the block gap too. A question
8
9
  * already carries the gap before it, and the row after one is the answer, so neither takes a seam.
9
10
  */
10
11
  export function rowRhythm(row, previous) {
11
12
  const question = row.kind === 'user';
12
- const seam = !question && previous !== null && previous.kind !== 'user' && isBlock(row) !== isBlock(previous);
13
+ const seam = !question && previous !== null && previous.kind !== 'user' && (isBlock(row) !== isBlock(previous) || previous.kind === 'visual');
13
14
  return clsx(question && 'pb-(--chat-answer-gap)', seam && 'pt-(--chat-block-gap)');
14
15
  }
15
16
  /*
@@ -434,6 +434,8 @@ declare const _default: {
434
434
  search: string;
435
435
  noProvider: string;
436
436
  noMatch: string;
437
+ allGroup_one: string;
438
+ allGroup_other: string;
437
439
  legacyGroup_one: string;
438
440
  legacyGroup_other: string;
439
441
  legacy: string;
@@ -434,6 +434,8 @@ export default {
434
434
  search: 'Search models',
435
435
  noProvider: 'No chat provider installed',
436
436
  noMatch: 'No models match',
437
+ allGroup_one: 'Models ({{count}})',
438
+ allGroup_other: 'Models ({{count}})',
437
439
  legacyGroup_one: 'Legacy models ({{count}})',
438
440
  legacyGroup_other: 'Legacy models ({{count}})',
439
441
  legacy: 'Legacy',
@@ -434,6 +434,8 @@ declare const _default: {
434
434
  search: string;
435
435
  noProvider: string;
436
436
  noMatch: string;
437
+ allGroup_one: string;
438
+ allGroup_other: string;
437
439
  legacyGroup_one: string;
438
440
  legacyGroup_other: string;
439
441
  legacy: string;
@@ -434,6 +434,8 @@ export default {
434
434
  search: 'Modellen zoeken',
435
435
  noProvider: 'Geen chat-CLI geïnstalleerd',
436
436
  noMatch: 'Geen modellen gevonden',
437
+ allGroup_one: 'Modellen ({{count}})',
438
+ allGroup_other: 'Modellen ({{count}})',
437
439
  legacyGroup_one: 'Legacy-modellen ({{count}})',
438
440
  legacyGroup_other: 'Legacy-modellen ({{count}})',
439
441
  legacy: 'Legacy',
@@ -30,8 +30,9 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }) {
30
30
  const { openLogin } = host;
31
31
  const loginBlocked = host.useLoginBlocked(scopeId);
32
32
  const [picked, setPicked] = useState(() => (target === 'providers.apple' ? { kind: 'cli', cli: 'apple' } : null));
33
- const installed = useMemo(() => providers.filter((provider) => provider.installed || provider.kind === 'apple'), [providers]);
34
- const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.kind !== 'apple'), [providers]);
33
+ // A provider that never runs in a terminal is no CLI to install: it is turned on in its own detail, so it is always listed.
34
+ const installed = useMemo(() => providers.filter((provider) => provider.installed || !provider.capabilities.terminal), [providers]);
35
+ const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.capabilities.terminal), [providers]);
35
36
  const entriesOf = (kind) => accountsOfKind(accounts, kind);
36
37
  const canAddAccount = (kind) => accounts?.loginCommands?.[kind] !== undefined;
37
38
  // What was picked may be gone since (an account removed elsewhere); the first CLI stands in for it.
@@ -59,7 +60,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }) {
59
60
  };
60
61
  const group = (cli) => {
61
62
  const entries = cli.installed ? entriesOf(cli.kind) : [];
62
- return (_jsxs("div", { className: "flex min-w-0 shrink-0 flex-col gap-0.5 pt-1", children: [_jsxs(ListRow, { selected: current?.kind === 'cli' && current.cli === cli.kind, onSelect: () => setPicked({ kind: 'cli', cli: cli.kind }), className: "pr-1", trailing: canAddAccount(cli.kind) && (_jsx(IconButton, { icon: Plus, size: "sm", label: t('list.addAccount', { provider: cli.name }), onClick: () => setPicked({ kind: 'add', cli: cli.kind }) })), children: [_jsx(CliMark, { kind: cli.kind }), _jsx("span", { className: "min-w-0 grow truncate text-xs font-medium text-text", children: cli.name }), cli.kind !== 'apple' && cli.version && _jsx("span", { className: "shrink-0 font-mono text-code text-text-faint", children: cli.version })] }), entries.map((entry) => {
63
+ return (_jsxs("div", { className: "flex min-w-0 shrink-0 flex-col gap-0.5 pt-1", children: [_jsxs(ListRow, { selected: current?.kind === 'cli' && current.cli === cli.kind, onSelect: () => setPicked({ kind: 'cli', cli: cli.kind }), className: "pr-1", trailing: canAddAccount(cli.kind) && (_jsx(IconButton, { icon: Plus, size: "sm", label: t('list.addAccount', { provider: cli.name }), onClick: () => setPicked({ kind: 'add', cli: cli.kind }) })), children: [_jsx(CliMark, { kind: cli.kind }), _jsx("span", { className: "min-w-0 grow truncate text-xs font-medium text-text", children: cli.name }), cli.capabilities.terminal && cli.version && _jsx("span", { className: "shrink-0 font-mono text-code text-text-faint", children: cli.version })] }), entries.map((entry) => {
63
64
  const name = accountName(entry, cli.name);
64
65
  const status = accountStatusLine(entry.status, canAddAccount(cli.kind));
65
66
  return (_jsxs(ListRow, { selected: current?.kind === 'account' && current.id === entry.id, onSelect: () => setPicked({ kind: 'account', id: entry.id }), children: [_jsx("span", { className: "grid w-3.5 shrink-0 place-items-center", children: _jsx(AccountDot, { color: entry.account.color, className: "size-2.25" }) }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-sm text-text", children: [_jsx("span", { className: "truncate", children: name }), entry.isDefault && (_jsx("span", { className: "shrink-0 rounded-md bg-surface-active px-1.5 text-xs text-text-muted", children: t('list.default') }))] }), _jsx("span", { className: clsx('truncate text-xs', ACCOUNT_TONE_CLASSES[status.tone]), children: status.text })] })] }, entry.id));
@@ -1,16 +1,16 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
3
  import { AgentIcon } from '../agents/AgentIcon.js';
4
- // Each CLI in its color of the usage charts, so a mark here reads as the same CLI there.
4
+ // Each CLI in its color of the usage charts, so a mark here reads as the same CLI there. A CLI without
5
+ // a chart color of its own is muted.
5
6
  const CLI_COLORS = {
6
- apple: 'text-text-muted',
7
7
  claude: 'text-chart-claude',
8
8
  codex: 'text-chart-codex',
9
9
  gemini: 'text-chart-gemini',
10
10
  copilot: 'text-chart-copilot'
11
11
  };
12
12
  export function CliMark({ kind, size = 14, className }) {
13
- return _jsx(AgentIcon, { kind: kind, size: size, className: clsx('shrink-0', CLI_COLORS[kind], className) });
13
+ return _jsx(AgentIcon, { kind: kind, size: size, className: clsx('shrink-0', CLI_COLORS[kind] ?? 'text-text-muted', className) });
14
14
  }
15
15
  /* As tall as the title's first line, so the mark sits beside the name rather than between the two lines. */
16
16
  export function CliTile({ kind }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.29.3",
3
+ "version": "0.30.0",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.29.3",
71
- "@adecore/editor": "0.29.3",
72
- "@adecore/editor-react": "0.29.3",
73
- "@adecore/intelligent-ui": "0.29.3",
74
- "@adecore/merge": "0.29.3",
70
+ "@adecore/agent-contracts": "0.30.0",
71
+ "@adecore/editor": "0.30.0",
72
+ "@adecore/editor-react": "0.30.0",
73
+ "@adecore/intelligent-ui": "0.30.0",
74
+ "@adecore/merge": "0.30.0",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.29.3",
97
+ "@adecore/ui": "^0.30.0",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.29.3",
104
+ "@adecore/ui": "0.30.0",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -1,23 +1,83 @@
1
- import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
2
2
 
3
- /* A model row, or the one row that folds the legacy models open. Both take a turn in the arrow keys. */
4
- export type PickerEntry = { kind: 'model'; provider: ProviderInfo; model: ModelInfo } | { kind: 'legacy'; count: number };
3
+ /*
4
+ * A provider with more live models than this keeps the list short: the model that is picked and the default,
5
+ * with the rest behind one row that opens all of them. A list of three hundred models is not one to scroll.
6
+ */
7
+ export const COMPACT_MODEL_LIMIT = 8;
8
+
9
+ /* A model row, or a row that folds more models open: one provider's models, or the legacy ones. All of them take a turn in the arrow keys. */
10
+ export type PickerEntry =
11
+ | { kind: 'model'; provider: ProviderInfo; model: ModelInfo }
12
+ | { kind: 'legacy'; count: number }
13
+ | { kind: 'all'; provider: ProviderInfo; count: number; open: boolean };
14
+
15
+ export interface PickerCurrent {
16
+ provider: AgentKind;
17
+ model: string;
18
+ }
5
19
 
6
20
  function matches(model: ModelInfo, query: string): boolean {
7
- return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
21
+ return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query) || (model.vendor?.toLowerCase().includes(query) ?? false);
8
22
  }
9
23
 
24
+ /*
25
+ * By maker, then by name, when the provider says who made its models; a list with no makers keeps the order
26
+ * it came in. A model with no maker goes last, so the ones that have one read as groups.
27
+ */
28
+ function byVendor(models: ModelInfo[]): ModelInfo[] {
29
+ if (!models.some((model) => model.vendor !== undefined)) {
30
+ return models;
31
+ }
32
+ return [...models].sort((a, b) => {
33
+ if (a.vendor === undefined || b.vendor === undefined) {
34
+ return a.vendor === b.vendor ? 0 : a.vendor === undefined ? 1 : -1;
35
+ }
36
+ return (
37
+ a.vendor.localeCompare(b.vendor, undefined, { sensitivity: 'base' }) ||
38
+ a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true })
39
+ );
40
+ });
41
+ }
42
+
43
+ const NONE: ReadonlySet<AgentKind> = new Set();
44
+
10
45
  /*
11
46
  * The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
12
- * only that model still finds the row that explains why it cannot be picked.
47
+ * only that model still finds the row that explains why it cannot be picked. A search looks through
48
+ * every model of every provider; without one, a provider with many models shows a short list and a row
49
+ * that opens the rest, grouped by maker.
13
50
  */
14
- export function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean): PickerEntry[] {
15
- const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model' as const, provider: entry, model })));
51
+ export function pickerEntriesOf(
52
+ providers: readonly ProviderInfo[],
53
+ query: string,
54
+ legacyOpen: boolean,
55
+ expanded: ReadonlySet<AgentKind> = NONE,
56
+ current: PickerCurrent | null = null
57
+ ): PickerEntry[] {
58
+ const rowsOf = (entry: ProviderInfo, models: ModelInfo[]): PickerEntry[] => byVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
16
59
  if (query !== '') {
17
- return rows.filter((row) => matches(row.model, query));
60
+ return providers.flatMap((entry) =>
61
+ rowsOf(
62
+ entry,
63
+ entry.models.filter((model) => matches(model, query))
64
+ )
65
+ );
18
66
  }
19
- const live = rows.filter((row) => !row.model.legacy);
20
- const legacy = rows.filter((row) => row.model.legacy);
67
+ const legacy = providers.flatMap((entry) =>
68
+ entry.models.filter((model) => model.legacy).map((model) => ({ kind: 'model' as const, provider: entry, model }))
69
+ );
70
+ const live = providers.flatMap((entry): PickerEntry[] => {
71
+ const models = entry.models.filter((model) => !model.legacy);
72
+ if (models.length <= COMPACT_MODEL_LIMIT) {
73
+ return rowsOf(entry, models);
74
+ }
75
+ if (expanded.has(entry.kind)) {
76
+ return [{ kind: 'all', provider: entry, count: models.length, open: true }, ...rowsOf(entry, models)];
77
+ }
78
+ const shortlist = models.filter((model) => model.isDefault || (current?.provider === entry.kind && current.model === model.slug));
79
+ return [...rowsOf(entry, shortlist), { kind: 'all', provider: entry, count: models.length, open: false }];
80
+ });
21
81
  if (legacy.length === 0) {
22
82
  return live;
23
83
  }
@@ -62,6 +62,7 @@ export function ModelPicker({
62
62
  const { t } = useTranslation('agent-chat');
63
63
  const [query, setQuery] = useState('');
64
64
  const [legacyOpen, setLegacyOpen] = useState(false);
65
+ const [expanded, setExpanded] = useState<ReadonlySet<AgentKind>>(new Set());
65
66
  const [index, setIndex] = useState(0);
66
67
  const inputRef = useRef<HTMLInputElement>(null);
67
68
  const listRef = useRef<HTMLDivElement>(null);
@@ -72,7 +73,10 @@ export function ModelPicker({
72
73
  const grouped = providers.length > 1;
73
74
  const trimmed = query.trim().toLowerCase();
74
75
 
75
- const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
76
+ const entries = useMemo(
77
+ () => pickerEntriesOf(providers, trimmed, legacyOpen, expanded, { provider, model: selection.model }),
78
+ [providers, trimmed, legacyOpen, expanded, provider, selection.model]
79
+ );
76
80
  const scope = useContext(ChatScopeContext);
77
81
  const machine = host.useMachineLabel(scope?.id ?? '');
78
82
  const reported = useReportedModels(provider);
@@ -85,6 +89,7 @@ export function ModelPicker({
85
89
  if (!next) {
86
90
  setQuery('');
87
91
  setLegacyOpen(false);
92
+ setExpanded(new Set());
88
93
  }
89
94
  setIndex(0);
90
95
  onOpenChange(next);
@@ -104,6 +109,18 @@ export function ModelPicker({
104
109
  inputRef.current?.focus();
105
110
  return;
106
111
  }
112
+ if (entry.kind === 'all') {
113
+ const kind = entry.provider.kind;
114
+ setExpanded((now) => {
115
+ const next = new Set(now);
116
+ if (!next.delete(kind)) {
117
+ next.add(kind);
118
+ }
119
+ return next;
120
+ });
121
+ inputRef.current?.focus();
122
+ return;
123
+ }
107
124
  if (requirementOf(entry) !== null) {
108
125
  return;
109
126
  }
@@ -165,6 +182,30 @@ export function ModelPicker({
165
182
  </div>
166
183
  )}
167
184
  {entries.map((entry, i) => {
185
+ if (entry.kind === 'all') {
186
+ const before = entries[i - 1];
187
+ const header = grouped && (before === undefined || before.kind === 'legacy' || before.provider.kind !== entry.provider.kind);
188
+ return (
189
+ <div key={`all-${entry.provider.kind}`}>
190
+ {header && (
191
+ <Menu.Label className="flex items-center gap-1.5">
192
+ <AgentIcon kind={entry.provider.kind} size={12} />
193
+ {entry.provider.name}
194
+ </Menu.Label>
195
+ )}
196
+ <button
197
+ data-active={i === index}
198
+ aria-expanded={entry.open}
199
+ className="cursor-row flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted"
200
+ onMouseEnter={() => setIndex(i)}
201
+ onClick={() => choose(entry)}
202
+ >
203
+ <Icon icon={entry.open ? ChevronDown : ChevronRight} size={12} />
204
+ {t('pickers.model.allGroup', { count: entry.count })}
205
+ </button>
206
+ </div>
207
+ );
208
+ }
168
209
  if (entry.kind === 'legacy') {
169
210
  return (
170
211
  <button
@@ -180,7 +221,17 @@ export function ModelPicker({
180
221
  );
181
222
  }
182
223
  const previous = entries[i - 1];
183
- const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
224
+ // A provider's rows start where the one before it ended; its row that opens the rest belongs to it.
225
+ const first = grouped && (previous === undefined || previous.kind === 'legacy' || previous.provider.kind !== entry.provider.kind);
226
+ // The makers read as groups only where the whole list is shown, so a short list is not cut by labels.
227
+ const vendorOpen = trimmed !== '' || expanded.has(entry.provider.kind);
228
+ const vendorFirst =
229
+ vendorOpen &&
230
+ entry.model.vendor !== undefined &&
231
+ (previous === undefined ||
232
+ previous.kind !== 'model' ||
233
+ previous.provider.kind !== entry.provider.kind ||
234
+ previous.model.vendor !== entry.model.vendor);
184
235
  const requirement = requirementOf(entry);
185
236
  const next = entries[i + 1];
186
237
  const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
@@ -193,6 +244,7 @@ export function ModelPicker({
193
244
  {entry.provider.name}
194
245
  </Menu.Label>
195
246
  )}
247
+ {vendorFirst && <Menu.Label>{entry.model.vendor}</Menu.Label>}
196
248
  <button
197
249
  role="option"
198
250
  aria-selected={chosen}
@@ -7,12 +7,13 @@ export const FOLLOW_THRESHOLD_PX = 40;
7
7
 
8
8
  /*
9
9
  * The gaps a row wears for where it sits: a question is followed by its answer gap, and a seam where
10
- * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A question
10
+ * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A visual has no
11
+ * margin of its own like prose does, so the row under one takes the block gap too. A question
11
12
  * already carries the gap before it, and the row after one is the answer, so neither takes a seam.
12
13
  */
13
14
  export function rowRhythm(row: TimelineRow, previous: TimelineRow | null): string {
14
15
  const question = row.kind === 'user';
15
- const seam = !question && previous !== null && previous.kind !== 'user' && isBlock(row) !== isBlock(previous);
16
+ const seam = !question && previous !== null && previous.kind !== 'user' && (isBlock(row) !== isBlock(previous) || previous.kind === 'visual');
16
17
  return clsx(question && 'pb-(--chat-answer-gap)', seam && 'pt-(--chat-block-gap)');
17
18
  }
18
19
 
@@ -434,6 +434,8 @@ export default {
434
434
  search: 'Search models',
435
435
  noProvider: 'No chat provider installed',
436
436
  noMatch: 'No models match',
437
+ allGroup_one: 'Models ({{count}})',
438
+ allGroup_other: 'Models ({{count}})',
437
439
  legacyGroup_one: 'Legacy models ({{count}})',
438
440
  legacyGroup_other: 'Legacy models ({{count}})',
439
441
  legacy: 'Legacy',
@@ -434,6 +434,8 @@ export default {
434
434
  search: 'Modellen zoeken',
435
435
  noProvider: 'Geen chat-CLI geïnstalleerd',
436
436
  noMatch: 'Geen modellen gevonden',
437
+ allGroup_one: 'Modellen ({{count}})',
438
+ allGroup_other: 'Modellen ({{count}})',
437
439
  legacyGroup_one: 'Legacy-modellen ({{count}})',
438
440
  legacyGroup_other: 'Legacy-modellen ({{count}})',
439
441
  legacy: 'Legacy',
@@ -68,8 +68,9 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
68
68
  const loginBlocked = host.useLoginBlocked(scopeId);
69
69
  const [picked, setPicked] = useState<Picked | null>(() => (target === 'providers.apple' ? { kind: 'cli', cli: 'apple' } : null));
70
70
 
71
- const installed = useMemo(() => providers.filter((provider) => provider.installed || provider.kind === 'apple'), [providers]);
72
- const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.kind !== 'apple'), [providers]);
71
+ // A provider that never runs in a terminal is no CLI to install: it is turned on in its own detail, so it is always listed.
72
+ const installed = useMemo(() => providers.filter((provider) => provider.installed || !provider.capabilities.terminal), [providers]);
73
+ const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.capabilities.terminal), [providers]);
73
74
  const entriesOf = (kind: AgentKind): AccountEntry[] => accountsOfKind(accounts, kind);
74
75
  const canAddAccount = (kind: AgentKind): boolean => accounts?.loginCommands?.[kind] !== undefined;
75
76
 
@@ -120,7 +121,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
120
121
  >
121
122
  <CliMark kind={cli.kind} />
122
123
  <span className="min-w-0 grow truncate text-xs font-medium text-text">{cli.name}</span>
123
- {cli.kind !== 'apple' && cli.version && <span className="shrink-0 font-mono text-code text-text-faint">{cli.version}</span>}
124
+ {cli.capabilities.terminal && cli.version && <span className="shrink-0 font-mono text-code text-text-faint">{cli.version}</span>}
124
125
  </ListRow>
125
126
  {entries.map((entry) => {
126
127
  const name = accountName(entry, cli.name);
@@ -2,9 +2,9 @@ import clsx from 'clsx';
2
2
  import type { AgentKind } from '@adecore/agent-contracts';
3
3
  import { AgentIcon } from '../agents/AgentIcon';
4
4
 
5
- // Each CLI in its color of the usage charts, so a mark here reads as the same CLI there.
6
- const CLI_COLORS: Record<AgentKind, string> = {
7
- apple: 'text-text-muted',
5
+ // Each CLI in its color of the usage charts, so a mark here reads as the same CLI there. A CLI without
6
+ // a chart color of its own is muted.
7
+ const CLI_COLORS: Partial<Record<AgentKind, string>> = {
8
8
  claude: 'text-chart-claude',
9
9
  codex: 'text-chart-codex',
10
10
  gemini: 'text-chart-gemini',
@@ -12,7 +12,7 @@ const CLI_COLORS: Record<AgentKind, string> = {
12
12
  };
13
13
 
14
14
  export function CliMark({ kind, size = 14, className }: { kind: AgentKind; size?: number; className?: string }) {
15
- return <AgentIcon kind={kind} size={size} className={clsx('shrink-0', CLI_COLORS[kind], className)} />;
15
+ return <AgentIcon kind={kind} size={size} className={clsx('shrink-0', CLI_COLORS[kind] ?? 'text-text-muted', className)} />;
16
16
  }
17
17
 
18
18
  /* As tall as the title's first line, so the mark sits beside the name rather than between the two lines. */