@adecore/agents-react 0.29.2 → 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.
- package/dist/chat/logic/picker-entries.d.ts +12 -2
- package/dist/chat/logic/picker-entries.js +41 -7
- package/dist/chat/ui/Pickers.js +31 -3
- package/dist/chat/ui/rows/row-rhythm.js +3 -2
- package/dist/locales/en/agent-chat.d.ts +2 -0
- package/dist/locales/en/agent-chat.js +2 -0
- package/dist/locales/nl/agent-chat.d.ts +2 -0
- package/dist/locales/nl/agent-chat.js +2 -0
- package/dist/providers/ProvidersPane.js +4 -3
- package/dist/providers/parts.js +3 -3
- package/package.json +8 -8
- package/src/chat/logic/picker-entries.ts +70 -10
- package/src/chat/ui/Pickers.tsx +54 -2
- package/src/chat/ui/rows/row-rhythm.ts +3 -2
- package/src/locales/en/agent-chat.ts +2 -0
- package/src/locales/nl/agent-chat.ts +2 -0
- package/src/providers/ProvidersPane.tsx +4 -3
- package/src/providers/parts.tsx +4 -4
|
@@ -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
|
|
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
|
|
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
|
|
35
|
+
return providers.flatMap((entry) => rowsOf(entry, entry.models.filter((model) => matches(model, query))));
|
|
12
36
|
}
|
|
13
|
-
const
|
|
14
|
-
const
|
|
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
|
}
|
package/dist/chat/ui/Pickers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 @@ 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',
|
|
@@ -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
|
-
|
|
34
|
-
const
|
|
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.
|
|
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));
|
package/dist/providers/parts.js
CHANGED
|
@@ -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.
|
|
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.
|
|
71
|
-
"@adecore/editor": "0.
|
|
72
|
-
"@adecore/editor-react": "0.
|
|
73
|
-
"@adecore/intelligent-ui": "0.
|
|
74
|
-
"@adecore/merge": "0.
|
|
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.
|
|
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.
|
|
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
|
-
/*
|
|
4
|
-
|
|
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(
|
|
15
|
-
|
|
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
|
|
60
|
+
return providers.flatMap((entry) =>
|
|
61
|
+
rowsOf(
|
|
62
|
+
entry,
|
|
63
|
+
entry.models.filter((model) => matches(model, query))
|
|
64
|
+
)
|
|
65
|
+
);
|
|
18
66
|
}
|
|
19
|
-
const
|
|
20
|
-
|
|
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
|
}
|
package/src/chat/ui/Pickers.tsx
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
const
|
|
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.
|
|
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);
|
package/src/providers/parts.tsx
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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. */
|