@adecore/agents-react 0.31.10 → 0.31.12

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.
@@ -17,4 +17,11 @@ export interface PickerCurrent {
17
17
  provider: AgentKind;
18
18
  model: string;
19
19
  }
20
+ export declare function modelsByVendor(models: ModelInfo[]): ModelInfo[];
21
+ export declare function shortlistOf(kind: AgentKind, models: ModelInfo[], current: PickerCurrent | null): ModelInfo[] | null;
22
+ export declare function wantsModelSearch(providers: readonly ProviderInfo[]): boolean;
23
+ export declare function searchModels(providers: readonly ProviderInfo[], query: string): Array<{
24
+ entry: ProviderInfo;
25
+ models: ModelInfo[];
26
+ }>;
20
27
  export declare function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean, expanded?: ReadonlySet<AgentKind>, current?: PickerCurrent | null): PickerEntry[];
@@ -10,7 +10,7 @@ function matches(model, query) {
10
10
  * By maker, then by name, when the provider says who made its models; a list with no makers keeps the order
11
11
  * it came in. A model with no maker goes last, so the ones that have one read as groups.
12
12
  */
13
- function byVendor(models) {
13
+ export function modelsByVendor(models) {
14
14
  if (!models.some((model) => model.vendor !== undefined)) {
15
15
  return models;
16
16
  }
@@ -22,6 +22,24 @@ function byVendor(models) {
22
22
  a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }));
23
23
  });
24
24
  }
25
+ /* The models a long list shows before it is opened: the default and the one that is picked. Null when the list is short enough to show whole. */
26
+ export function shortlistOf(kind, models, current) {
27
+ if (models.length <= COMPACT_MODEL_LIMIT) {
28
+ return null;
29
+ }
30
+ return modelsByVendor(models.filter((model) => model.isDefault || (current?.provider === kind && current.model === model.slug)));
31
+ }
32
+ /* Whether the providers offer enough models together that a search is quicker than reading the list. */
33
+ export function wantsModelSearch(providers) {
34
+ return providers.reduce((count, entry) => count + entry.models.length, 0) > COMPACT_MODEL_LIMIT;
35
+ }
36
+ /* Every model of every provider that a search finds, legacy ones included, by maker; a provider with none is left out. */
37
+ export function searchModels(providers, query) {
38
+ const needle = query.trim().toLowerCase();
39
+ return providers
40
+ .map((entry) => ({ entry, models: modelsByVendor(entry.models.filter((model) => matches(model, needle))) }))
41
+ .filter((group) => group.models.length > 0);
42
+ }
25
43
  const NONE = new Set();
26
44
  /*
27
45
  * The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
@@ -30,20 +48,20 @@ const NONE = new Set();
30
48
  * that opens the rest, grouped by maker.
31
49
  */
32
50
  export function pickerEntriesOf(providers, query, legacyOpen, expanded = NONE, current = null) {
33
- const rowsOf = (entry, models) => byVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
51
+ const rowsOf = (entry, models) => modelsByVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
34
52
  if (query !== '') {
35
53
  return providers.flatMap((entry) => rowsOf(entry, entry.models.filter((model) => matches(model, query))));
36
54
  }
37
55
  const legacy = providers.flatMap((entry) => entry.models.filter((model) => model.legacy).map((model) => ({ kind: 'model', provider: entry, model })));
38
56
  const live = providers.flatMap((entry) => {
39
57
  const models = entry.models.filter((model) => !model.legacy);
40
- if (models.length <= COMPACT_MODEL_LIMIT) {
58
+ const shortlist = shortlistOf(entry.kind, models, current);
59
+ if (shortlist === null) {
41
60
  return rowsOf(entry, models);
42
61
  }
43
62
  if (expanded.has(entry.kind)) {
44
63
  return [{ kind: 'all', provider: entry, count: models.length, open: true }, ...rowsOf(entry, models)];
45
64
  }
46
- const shortlist = models.filter((model) => model.isDefault || (current?.provider === entry.kind && current.model === model.slug));
47
65
  return [...rowsOf(entry, shortlist), { kind: 'all', provider: entry, count: models.length, open: false }];
48
66
  });
49
67
  if (legacy.length === 0) {
@@ -0,0 +1,4 @@
1
+ import type { ModelInfo } from '@adecore/agent-contracts';
2
+ export declare function ModelMarks({ model }: {
3
+ model: ModelInfo;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Wrench } from 'lucide-react';
4
+ import { Icon, Pill, Tooltip } from '@adecore/ui';
5
+ /* What a model row says beside the name: its badge, and a mark when the provider says the model can call tools. */
6
+ export function ModelMarks({ model }) {
7
+ const { t } = useTranslation('agent-chat');
8
+ return (_jsxs(_Fragment, { children: [model.badge && (_jsx(Pill, { tone: "accent", shape: "tag", size: "sm", children: model.badge.charAt(0).toLocaleUpperCase() + model.badge.slice(1) })), model.tools === true && (_jsx(Tooltip, { label: t('pickers.model.tools'), children: _jsx("span", { className: "flex shrink-0 text-text-faint", "aria-label": t('pickers.model.tools'), children: _jsx(Icon, { icon: Wrench, size: 12 }) }) }))] }));
9
+ }
@@ -9,9 +9,10 @@ import { modelRequirementOf } from '../../agents/model-requirement.js';
9
9
  import { useChatHost } from '../../host.js';
10
10
  import { ChatScopeContext } from '../../scope.js';
11
11
  import { useReportedModels } from '../../state/chats.js';
12
+ import { ModelMarks } from './ModelMarks.js';
12
13
  import { pickerEntriesOf } from '../logic/picker-entries.js';
13
14
  import { forgetStashed, STASH_SHORTCUT, useStash } from '../stash.js';
14
- import { Icon, IconButton, Menu, Pill, Popover, Tooltip } from '@adecore/ui';
15
+ import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
15
16
  const triggerClass = 'flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text';
16
17
  const chipClass = 'flex h-6 min-w-0 shrink items-center gap-1.5 whitespace-nowrap rounded-md bg-surface-hover px-2 text-xs text-text hover:bg-surface-active data-[popup-open]:bg-surface-active';
17
18
  /*
@@ -122,7 +123,7 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
122
123
  const next = entries[i + 1];
123
124
  const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
124
125
  const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
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
126
+ 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 }), _jsx(ModelMarks, { model: entry.model }), _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
126
127
  ? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
127
128
  : 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: () => {
128
129
  setOpen(false);
@@ -1,16 +1,19 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment, useEffect, useRef, useState } from 'react';
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import clsx from 'clsx';
4
- import { Activity, TriangleAlert, ChevronDown, ChevronRight, ChevronUp, FilePen, Gauge, Hand, Settings, Shield, ShieldAlert, SlidersHorizontal } from 'lucide-react';
5
+ import { Activity, TriangleAlert, ChevronDown, ChevronRight, ChevronUp, FilePen, Gauge, Hand, Search, Settings, Shield, ShieldAlert, SlidersHorizontal } from 'lucide-react';
5
6
  import { AccountDot } from '../../agents/AccountDot.js';
6
7
  import { limitsOfAccount, sessionWindow } from '../../agents/account-limits.js';
7
8
  import { canContinueOn } from '../../agents/accounts.js';
8
9
  import { AgentIcon } from '../../agents/AgentIcon.js';
9
10
  import { modelName, shortModelName } from '../../agents/model-name.js';
11
+ import { ModelMarks } from './ModelMarks.js';
12
+ import { modelsByVendor, searchModels, shortlistOf, wantsModelSearch } from '../logic/picker-entries.js';
10
13
  import { CONTEXT_OPTION, contextFraction, contextSegments, orderOptions } from '../logic/context-usage.js';
11
14
  import { RUNTIME_MODES } from '../runtime-modes.js';
12
15
  import { formatClock, formatWeekdayClock, isSameDay, formatPercent, formatTokens } from '@adecore/ui/format';
13
- import { Icon, Menu, Pill, Tooltip, useNow } from '@adecore/ui';
16
+ import { Icon, Menu, Tooltip, useNow } from '@adecore/ui';
14
17
  import { useChatHost } from '../../host.js';
15
18
  import { useUsageLimits } from '../../usage/limits.js';
16
19
  const MINUTE_MS = 60_000;
@@ -59,6 +62,8 @@ function modelGroupsOf(providers, legacy) {
59
62
  */
60
63
  export function RunSettings({ disabled = false, providers, provider, selection, model, runtimeMode, effectiveRuntimeMode, permissionMode, open, onOpenChange, onModel, onOption, onMode, usage, compactDisabled, onCompact, account }) {
61
64
  const { t } = useTranslation('agent-chat');
65
+ const [query, setQuery] = useState('');
66
+ const searchRef = useRef(null);
62
67
  const owner = providers.find((entry) => entry.kind === provider);
63
68
  const current = modelName(selection.model, owner?.models);
64
69
  const short = shortModelName(current, owner?.models);
@@ -87,8 +92,70 @@ export function RunSettings({ disabled = false, providers, provider, selection,
87
92
  const slash = value.indexOf('/');
88
93
  onModel(value.slice(0, slash), value.slice(slash + 1));
89
94
  };
90
- const modelGroups = (groups) => groups.map(({ entry, models }) => (_jsxs(Menu.Group, { children: [grouped && (_jsxs(Menu.GroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.kind, size: 12 }), entry.name] })), models.map((row) => (_jsxs(Menu.RadioItem, { value: modelValue(entry.kind, row.slug), closeOnClick: true, children: [_jsx(AgentIcon, { kind: entry.kind, size: 14, className: "shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: row.name }), row.badge && (_jsx(Pill, { tone: "accent", shape: "tag", size: "sm", children: row.badge.charAt(0).toLocaleUpperCase() + row.badge.slice(1) }))] }, row.slug)))] }, entry.kind)));
91
- return (_jsxs(Menu.Root, { open: open, onOpenChange: onOpenChange, children: [_jsx(Tooltip, { label: permissionHint ?? t('pickers.settings.title'), kbd: "/model", children: _jsxs(Menu.Trigger, { disabled: disabled, className: clsx('flex h-8 min-w-0 items-center gap-2 rounded-full border pr-3 pl-2.5 text-xs whitespace-nowrap text-text-muted @max-xl/composer:gap-1.5 @max-sm/composer:pr-2.5', open ? 'border-accent bg-accent-soft' : 'border-border hover:bg-surface-hover'), children: [_jsx(AgentIcon, { kind: provider, size: 14, className: "shrink-0" }), _jsxs("span", { className: "flex min-w-0 items-center gap-2 overflow-hidden @max-xl/composer:gap-1.5", children: [_jsx("span", { className: "truncate font-medium text-text @max-xl/composer:hidden", children: current }), _jsx("span", { className: "hidden truncate font-medium text-text @max-xl/composer:inline", children: short }), account !== null && (_jsxs("span", { className: "flex min-w-0 items-center gap-2 @max-xl/composer:gap-1.5", children: [_jsx("span", { className: "shrink-0 text-text-faint", children: "\u00B7" }), _jsx(AccountDot, { color: account.choice.current?.account.color }), _jsx("span", { className: "truncate @max-xl/composer:hidden", children: account.choice.current === null ? account.choice.currentId : account.choice.nameOf(account.choice.current) })] })), summary.map((entry) => (_jsxs("span", { className: clsx('flex shrink-0 items-center gap-2 @max-xl/composer:gap-1.5', !entry.ring && '@max-xl/composer:hidden'), children: [_jsx("span", { className: "text-text-faint", children: "\u00B7" }), entry.ring && _jsx(ContextRing, { usage: usage }), _jsx("span", { className: clsx(entry.ring && '@max-sm/composer:hidden'), children: entry.label })] }, entry.id))), _jsx("span", { className: "shrink-0 text-text-faint", children: "\u00B7" }), _jsxs("span", { className: clsx('flex shrink-0 items-center gap-1', fullAccess && 'text-status-needs-you'), children: [_jsx(Icon, { icon: MODE_ICONS[shownMode], size: 12, className: "shrink-0" }), _jsx("span", { children: effectiveRuntimeMode === undefined && permissionMode !== undefined ? permissionMode : t(`modes.${shownMode}.short`) }), permissionFallback && _jsx(Icon, { icon: TriangleAlert, size: 12, className: "shrink-0 text-status-needs-you" })] })] }), _jsx(Icon, { icon: open ? ChevronUp : ChevronDown, size: 12, className: "shrink-0 @max-sm/composer:hidden" })] }) }), _jsxs(Menu.Popup, { side: "top", sideOffset: 8, align: "start", className: "w-80", children: [providers.length === 0 && _jsx("div", { className: "px-2.5 py-3 text-xs text-text-faint", children: t('pickers.model.noProvider') }), _jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: modelGroups(live) }), legacy.length > 0 && (_jsxs(Menu.SubmenuRoot, { children: [_jsxs(Menu.SubmenuTrigger, { className: "text-text-muted", children: [_jsx("span", { className: "min-w-0 grow truncate", children: t('pickers.model.legacyGroup', { count: legacy.reduce((count, group) => count + group.models.length, 0) }) }), chosenLegacy && _jsx(Menu.Hint, { className: "truncate", children: chosenLegacy.name })] }), _jsx(Menu.Popup, { className: "min-w-56", children: _jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: modelGroups(legacy) }) })] })), _jsx(Menu.Separator, {}), account !== null && _jsx(AccountSubmenu, { provider: provider, account: account }), _jsxs(Menu.SubmenuRoot, { children: [_jsxs(Menu.SubmenuTrigger, { className: "text-text-muted", children: [_jsx(Icon, { icon: MODE_ICONS[runtimeMode], size: 14, className: clsx('shrink-0', fullAccess ? 'text-status-needs-you' : 'text-text-faint') }), _jsx("span", { className: "min-w-0 grow truncate", children: t('pickers.mode.label') }), _jsx(Menu.Hint, { className: "truncate", children: t(`modes.${runtimeMode}.label`) })] }), _jsxs(Menu.Popup, { className: "w-72", children: [permissionHint !== null && _jsx("p", { className: "px-2.5 py-2 text-xs text-text-muted", children: permissionHint }), _jsx(Menu.RadioGroup, { value: runtimeMode, onValueChange: (mode) => onMode(mode), children: RUNTIME_MODES.map((mode) => (_jsxs(Menu.RadioItem, { value: mode, className: "items-start", children: [_jsx("span", { className: "grid h-lh w-4 shrink-0 place-items-center", children: _jsx(Icon, { icon: MODE_ICONS[mode], size: 14, className: mode === 'full-access' ? 'text-status-needs-you' : 'text-text-faint' }) }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [t(`modes.${mode}.label`), _jsx("span", { className: "text-xs text-text-faint", children: t(`modes.${mode}.hint`) })] })] }, mode))) })] })] }), options.map((option) => (_jsx(OptionRow, { option: option, selection: selection, onChange: (value) => onOption(option.id, value) }, option.id))), (hasContextOption || standaloneContext) && (_jsxs(_Fragment, { children: [_jsx(Menu.Separator, {}), _jsx(ContextUsage, { usage: usage, disabled: compactDisabled, onCompact: onCompact })] }))] })] }));
95
+ const searchable = wantsModelSearch(providers);
96
+ const found = query.trim() === '' ? null : searchModels(providers, query);
97
+ // The menu moves the focus to its popup as it opens, so the search field takes it a frame later.
98
+ useEffect(() => {
99
+ if (!open || !searchable) {
100
+ return;
101
+ }
102
+ const frame = requestAnimationFrame(() => searchRef.current?.focus());
103
+ return () => cancelAnimationFrame(frame);
104
+ }, [open, searchable]);
105
+ const changeOpen = (next) => {
106
+ if (!next) {
107
+ setQuery('');
108
+ }
109
+ onOpenChange(next);
110
+ };
111
+ // An item the arrow keys moved to has the focus; typing there still goes to the search.
112
+ const onPopupKey = (event) => {
113
+ if (!searchable || event.target === searchRef.current || event.metaKey || event.ctrlKey || event.altKey) {
114
+ return;
115
+ }
116
+ const typed = event.key.length === 1 && event.key !== ' ';
117
+ if (!typed && event.key !== 'Backspace') {
118
+ return;
119
+ }
120
+ event.preventDefault();
121
+ event.stopPropagation();
122
+ setQuery((now) => (typed ? now + event.key : now.slice(0, -1)));
123
+ searchRef.current?.focus();
124
+ };
125
+ // The menu reads letters as a jump to an item and arrows as moving through them; the field keeps what it types.
126
+ const onSearchKey = (event) => {
127
+ if (event.key === 'Escape' && query !== '') {
128
+ event.preventDefault();
129
+ event.stopPropagation();
130
+ setQuery('');
131
+ return;
132
+ }
133
+ if (event.key === 'Enter') {
134
+ event.preventDefault();
135
+ const first = found?.[0];
136
+ if (first !== undefined) {
137
+ onModel(first.entry.kind, first.models[0].slug);
138
+ changeOpen(false);
139
+ }
140
+ return;
141
+ }
142
+ if (event.key !== 'Escape' && event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Tab') {
143
+ event.stopPropagation();
144
+ }
145
+ };
146
+ const modelRow = (entry, row) => (_jsxs(Menu.RadioItem, { value: modelValue(entry.kind, row.slug), closeOnClick: true, children: [_jsx(AgentIcon, { kind: entry.kind, size: 14, className: "shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: row.name }), _jsx(ModelMarks, { model: row })] }, row.slug));
147
+ // Every model of one provider, under a label per maker where the provider names them.
148
+ const byMaker = (entry, models) => modelsByVendor(models).map((row, i, sorted) => (_jsxs(Fragment, { children: [row.vendor !== undefined && sorted[i - 1]?.vendor !== row.vendor && _jsx(Menu.GroupLabel, { children: row.vendor }), modelRow(entry, row)] }, row.slug)));
149
+ // A provider with many models shows the default and the chosen one, with all of them one submenu away.
150
+ const providerModels = (entry, models) => {
151
+ const shortlist = shortlistOf(entry.kind, models, { provider, model: selection.model });
152
+ if (shortlist === null) {
153
+ return models.map((row) => modelRow(entry, row));
154
+ }
155
+ return (_jsxs(_Fragment, { children: [shortlist.map((row) => modelRow(entry, row)), _jsxs(Menu.SubmenuRoot, { children: [_jsx(Menu.SubmenuTrigger, { className: "text-text-muted", children: t('pickers.model.allGroup', { count: models.length }) }), _jsx(Menu.Popup, { className: "w-80", children: _jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: byMaker(entry, models) }) })] })] }));
156
+ };
157
+ const modelGroups = (groups) => groups.map(({ entry, models }) => (_jsxs(Menu.Group, { children: [grouped && (_jsxs(Menu.GroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.kind, size: 12 }), entry.name] })), providerModels(entry, models)] }, entry.kind)));
158
+ return (_jsxs(Menu.Root, { open: open, onOpenChange: changeOpen, highlightItemOnHover: !searchable, children: [_jsx(Tooltip, { label: permissionHint ?? t('pickers.settings.title'), kbd: "/model", children: _jsxs(Menu.Trigger, { disabled: disabled, className: clsx('flex h-8 min-w-0 items-center gap-2 rounded-full border pr-3 pl-2.5 text-xs whitespace-nowrap text-text-muted @max-xl/composer:gap-1.5 @max-sm/composer:pr-2.5', open ? 'border-accent bg-accent-soft' : 'border-border hover:bg-surface-hover'), children: [_jsx(AgentIcon, { kind: provider, size: 14, className: "shrink-0" }), _jsxs("span", { className: "flex min-w-0 items-center gap-2 overflow-hidden @max-xl/composer:gap-1.5", children: [_jsx("span", { className: "truncate font-medium text-text @max-xl/composer:hidden", children: current }), _jsx("span", { className: "hidden truncate font-medium text-text @max-xl/composer:inline", children: short }), account !== null && (_jsxs("span", { className: "flex min-w-0 items-center gap-2 @max-xl/composer:gap-1.5", children: [_jsx("span", { className: "shrink-0 text-text-faint", children: "\u00B7" }), _jsx(AccountDot, { color: account.choice.current?.account.color }), _jsx("span", { className: "truncate @max-xl/composer:hidden", children: account.choice.current === null ? account.choice.currentId : account.choice.nameOf(account.choice.current) })] })), summary.map((entry) => (_jsxs("span", { className: clsx('flex shrink-0 items-center gap-2 @max-xl/composer:gap-1.5', !entry.ring && '@max-xl/composer:hidden'), children: [_jsx("span", { className: "text-text-faint", children: "\u00B7" }), entry.ring && _jsx(ContextRing, { usage: usage }), _jsx("span", { className: clsx(entry.ring && '@max-sm/composer:hidden'), children: entry.label })] }, entry.id))), _jsx("span", { className: "shrink-0 text-text-faint", children: "\u00B7" }), _jsxs("span", { className: clsx('flex shrink-0 items-center gap-1', fullAccess && 'text-status-needs-you'), children: [_jsx(Icon, { icon: MODE_ICONS[shownMode], size: 12, className: "shrink-0" }), _jsx("span", { children: effectiveRuntimeMode === undefined && permissionMode !== undefined ? permissionMode : t(`modes.${shownMode}.short`) }), permissionFallback && _jsx(Icon, { icon: TriangleAlert, size: 12, className: "shrink-0 text-status-needs-you" })] })] }), _jsx(Icon, { icon: open ? ChevronUp : ChevronDown, size: 12, className: "shrink-0 @max-sm/composer:hidden" })] }) }), _jsxs(Menu.Popup, { side: "top", sideOffset: 8, align: "start", className: "w-80", onKeyDownCapture: onPopupKey, children: [providers.length === 0 && _jsx("div", { className: "px-2.5 py-3 text-xs text-text-faint", children: t('pickers.model.noProvider') }), searchable && (_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-2.5", children: [_jsx(Icon, { icon: Search, size: 14, className: "shrink-0 text-text-faint" }), _jsx("input", { ref: searchRef, className: "h-8 w-full bg-transparent text-sm text-text outline-none placeholder:text-text-faint", placeholder: t('pickers.model.search'), "aria-label": t('pickers.model.search'), spellCheck: false, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: onSearchKey })] })), found !== null && found.length === 0 && _jsx("div", { className: "px-2.5 py-3 text-xs text-text-faint", children: t('pickers.model.noMatch') }), found !== null && found.length > 0 && (_jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: found.map(({ entry, models }) => (_jsxs(Menu.Group, { children: [grouped && (_jsxs(Menu.GroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.kind, size: 12 }), entry.name] })), byMaker(entry, models)] }, entry.kind))) })), found === null && (_jsxs(_Fragment, { children: [_jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: modelGroups(live) }), legacy.length > 0 && (_jsxs(Menu.SubmenuRoot, { children: [_jsxs(Menu.SubmenuTrigger, { className: "text-text-muted", children: [_jsx("span", { className: "min-w-0 grow truncate", children: t('pickers.model.legacyGroup', { count: legacy.reduce((count, group) => count + group.models.length, 0) }) }), chosenLegacy && _jsx(Menu.Hint, { className: "truncate", children: chosenLegacy.name })] }), _jsx(Menu.Popup, { className: "min-w-56", children: _jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: modelGroups(legacy) }) })] })), _jsx(Menu.Separator, {}), account !== null && _jsx(AccountSubmenu, { provider: provider, account: account }), _jsxs(Menu.SubmenuRoot, { children: [_jsxs(Menu.SubmenuTrigger, { className: "text-text-muted", children: [_jsx(Icon, { icon: MODE_ICONS[runtimeMode], size: 14, className: clsx('shrink-0', fullAccess ? 'text-status-needs-you' : 'text-text-faint') }), _jsx("span", { className: "min-w-0 grow truncate", children: t('pickers.mode.label') }), _jsx(Menu.Hint, { className: "truncate", children: t(`modes.${runtimeMode}.label`) })] }), _jsxs(Menu.Popup, { className: "w-72", children: [permissionHint !== null && _jsx("p", { className: "px-2.5 py-2 text-xs text-text-muted", children: permissionHint }), _jsx(Menu.RadioGroup, { value: runtimeMode, onValueChange: (mode) => onMode(mode), children: RUNTIME_MODES.map((mode) => (_jsxs(Menu.RadioItem, { value: mode, className: "items-start", children: [_jsx("span", { className: "grid h-lh w-4 shrink-0 place-items-center", children: _jsx(Icon, { icon: MODE_ICONS[mode], size: 14, className: mode === 'full-access' ? 'text-status-needs-you' : 'text-text-faint' }) }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [t(`modes.${mode}.label`), _jsx("span", { className: "text-xs text-text-faint", children: t(`modes.${mode}.hint`) })] })] }, mode))) })] })] }), options.map((option) => (_jsx(OptionRow, { option: option, selection: selection, onChange: (value) => onOption(option.id, value) }, option.id))), (hasContextOption || standaloneContext) && (_jsxs(_Fragment, { children: [_jsx(Menu.Separator, {}), _jsx(ContextUsage, { usage: usage, disabled: compactDisabled, onCompact: onCompact })] }))] }))] })] }));
92
159
  }
93
160
  /* Red where a window is nearly spent, amber where it is worth knowing, as the usage bars have it. */
94
161
  function sessionTone(used) {
@@ -11,7 +11,7 @@ import { useChatHost } from '../../../host.js';
11
11
  import { lazyNamed } from '../../../lazy.js';
12
12
  import { useChatActions } from '../../actions.js';
13
13
  import { ChatScopeContext, useChatScope } from '../../../scope.js';
14
- import { useCurrentItem } from '../../../state/chats.js';
14
+ import { useAwaitsApproval, useCurrentItem } from '../../../state/chats.js';
15
15
  import { useTimelineFlash } from '../../timeline-flash.js';
16
16
  import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
17
17
  import { Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
@@ -147,7 +147,9 @@ export function WorkLiveRow({ chatId, tool: derived }) {
147
147
  const shell = shellCommand(tool.name, tool.input);
148
148
  const { commandLooks } = useChatHost();
149
149
  const run = shell === null ? null : matchCommandLook(shell, commandLooks);
150
- return (_jsxs("div", { children: [_jsx(ToggleLine, { icon: run === null ? toolIcon(tool.name) : _jsx(run.look.Mark, { size: 12 }), label: run === null ? tool.name : run.match.label, brand: run?.look.brand, look: run === null ? undefined : { run, chatId }, detail: summary || tool.progress?.description || '', command: summary !== '' && summary === shellCommand(tool.name, tool.input), path: summary !== '' && toolSummaryIsPath(tool.name, tool.input), open: open && opens, onToggle: opens ? () => setOpen((o) => !o) : undefined, trailing: _jsx(RunningFor, { startedAt: toolStartedAt(tool) }), live: true }), open && opens && _jsx(ToolBody, { tool: tool }), tail !== null && !open && (_jsx("pre", { className: "mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-chat-output select-text", children: _jsx(AnsiOutput, { text: tail, tailLines: 12 }) }))] }));
150
+ const awaiting = useAwaitsApproval(chatId, tool.toolUseId);
151
+ const { t } = useTranslation('agent-chat');
152
+ return (_jsxs("div", { children: [_jsx(ToggleLine, { icon: run === null ? toolIcon(tool.name) : _jsx(run.look.Mark, { size: 12 }), label: run === null ? tool.name : run.match.label, brand: run?.look.brand, look: run === null ? undefined : { run, chatId }, detail: summary || tool.progress?.description || '', command: summary !== '' && summary === shellCommand(tool.name, tool.input), path: summary !== '' && toolSummaryIsPath(tool.name, tool.input), open: open && opens, onToggle: opens ? () => setOpen((o) => !o) : undefined, trailing: awaiting ? (_jsx("span", { className: "shrink-0 text-xs text-text-faint", children: t('work.awaitingApproval') })) : (_jsx(RunningFor, { startedAt: toolStartedAt(tool) })), live: true }), open && opens && _jsx(ToolBody, { tool: tool }), tail !== null && !open && (_jsx("pre", { className: "mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-chat-output select-text", children: _jsx(AnsiOutput, { text: tail, tailLines: 12 }) }))] }));
151
153
  }
152
154
  /* A run of tool calls as one line ("Read 4 files"); opening it lists every call. A run of one look wears its mark and its brand. */
153
155
  export function WorkGroupRow({ tools, summary, look, expanded, onToggle }) {
@@ -183,6 +183,7 @@ declare const _default: {
183
183
  loadingDiff: string;
184
184
  diffUnavailable: string;
185
185
  runningFor: string;
186
+ awaitingApproval: string;
186
187
  forksAfter_one: string;
187
188
  forksAfter_other: string;
188
189
  omitted: {
@@ -439,6 +440,7 @@ declare const _default: {
439
440
  legacyGroup_one: string;
440
441
  legacyGroup_other: string;
441
442
  legacy: string;
443
+ tools: string;
442
444
  requires: string;
443
445
  requiresHere: string;
444
446
  providerSettings: string;
@@ -183,6 +183,7 @@ export default {
183
183
  loadingDiff: 'Loading diff',
184
184
  diffUnavailable: 'Could not load this diff',
185
185
  runningFor: 'running for {{elapsed}}',
186
+ awaitingApproval: 'waiting for you',
186
187
  forksAfter_one: 'A fork goes on after this turn',
187
188
  forksAfter_other: '{{count}} forks go on after this turn',
188
189
  omitted: {
@@ -439,6 +440,7 @@ export default {
439
440
  legacyGroup_one: 'Legacy models ({{count}})',
440
441
  legacyGroup_other: 'Legacy models ({{count}})',
441
442
  legacy: 'Legacy',
443
+ tools: 'Can use tools',
442
444
  requires: 'Requires {{cli}} {{version}} or later on {{machine}}',
443
445
  requiresHere: 'Requires {{cli}} {{version}} or later',
444
446
  providerSettings: 'Provider settings'
@@ -183,6 +183,7 @@ declare const _default: {
183
183
  loadingDiff: string;
184
184
  diffUnavailable: string;
185
185
  runningFor: string;
186
+ awaitingApproval: string;
186
187
  forksAfter_one: string;
187
188
  forksAfter_other: string;
188
189
  omitted: {
@@ -439,6 +440,7 @@ declare const _default: {
439
440
  legacyGroup_one: string;
440
441
  legacyGroup_other: string;
441
442
  legacy: string;
443
+ tools: string;
442
444
  requires: string;
443
445
  requiresHere: string;
444
446
  providerSettings: string;
@@ -183,6 +183,7 @@ export default {
183
183
  loadingDiff: 'Diff laden',
184
184
  diffUnavailable: 'Deze diff kon niet worden geladen',
185
185
  runningFor: 'loopt al {{elapsed}}',
186
+ awaitingApproval: 'wacht op jou',
186
187
  forksAfter_one: 'Een fork gaat verder na deze turn',
187
188
  forksAfter_other: '{{count}} forks gaan verder na deze turn',
188
189
  omitted: {
@@ -439,6 +440,7 @@ export default {
439
440
  legacyGroup_one: 'Legacy-modellen ({{count}})',
440
441
  legacyGroup_other: 'Legacy-modellen ({{count}})',
441
442
  legacy: 'Legacy',
443
+ tools: 'Kan tools gebruiken',
442
444
  requires: 'Vereist {{cli}} {{version}} of nieuwer op {{machine}}',
443
445
  requiresHere: 'Vereist {{cli}} {{version}} of nieuwer',
444
446
  providerSettings: 'Providerinstellingen'
@@ -22,7 +22,9 @@ function ApprovalDetails({ item }) {
22
22
  const editorPreview = host.code.editor !== undefined;
23
23
  const maxHeight = expanded ? 480 : 176;
24
24
  const input = typeof item.input === 'object' && item.input !== null ? item.input : {};
25
- return (_jsxs(_Fragment, { children: [item.description && _jsx("p", { className: "text-sm text-text-muted", children: item.description }), patches.length + edits.length > 1 && (_jsx("p", { className: "text-xs text-text-muted", children: t('approval.allChanges', { count: patches.length + edits.length }) })), patches.length + edits.length > 0 ? (_jsxs("div", { className: "overflow-hidden rounded-xl bg-surface-sunken", children: [_jsx("div", { className: clsx(editorPreview ? 'overflow-hidden' : 'overflow-auto', !editorPreview && !expanded && 'max-h-44'), children: _jsx(ApprovalReview, { entries: entries, maxHeight: maxHeight }, item.requestId) }), _jsx("div", { className: "flex items-center border-t border-border p-1", children: _jsx(Button, { size: "sm", "aria-expanded": expanded, onClick: () => setExpanded(!expanded), children: expanded ? t('approval.collapseDiff') : t('approval.viewDiff') }) })] })) : typeof input.command === 'string' ? (_jsx(CommandBox, { command: input.command, cwd: typeof input.cwd === 'string' ? input.cwd : undefined })) : (_jsxs("details", { children: [_jsx("summary", { className: "flex h-7 cursor-pointer items-center text-xs text-text-muted", children: t('approval.details') }), _jsx(TextMenu, { children: _jsx("pre", { className: "max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-surface-sunken p-3 font-mono text-code text-text select-text", children: JSON.stringify(item.input, null, 2) }) })] }))] }));
25
+ // Some agents describe a command by the command itself, which the box under it already shows.
26
+ const describes = item.description && item.description.trim() !== (typeof input.command === 'string' ? input.command.trim() : null);
27
+ return (_jsxs(_Fragment, { children: [describes && _jsx("p", { className: "text-sm text-text-muted", children: item.description }), patches.length + edits.length > 1 && (_jsx("p", { className: "text-xs text-text-muted", children: t('approval.allChanges', { count: patches.length + edits.length }) })), patches.length + edits.length > 0 ? (_jsxs("div", { className: "overflow-hidden rounded-xl bg-surface-sunken", children: [_jsx("div", { className: clsx(editorPreview ? 'overflow-hidden' : 'overflow-auto', !editorPreview && !expanded && 'max-h-44'), children: _jsx(ApprovalReview, { entries: entries, maxHeight: maxHeight }, item.requestId) }), _jsx("div", { className: "flex items-center border-t border-border p-1", children: _jsx(Button, { size: "sm", "aria-expanded": expanded, onClick: () => setExpanded(!expanded), children: expanded ? t('approval.collapseDiff') : t('approval.viewDiff') }) })] })) : typeof input.command === 'string' ? (_jsx(CommandBox, { command: input.command, cwd: typeof input.cwd === 'string' ? input.cwd : undefined })) : (_jsxs("details", { children: [_jsx("summary", { className: "flex h-7 cursor-pointer items-center text-xs text-text-muted", children: t('approval.details') }), _jsx(TextMenu, { children: _jsx("pre", { className: "max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-surface-sunken p-3 font-mono text-code text-text select-text", children: JSON.stringify(item.input, null, 2) }) })] }))] }));
26
28
  }
27
29
  /* A chat's permission request: the diff, the command or the raw input, and the reason a Deny may carry. */
28
30
  export function ApprovalBody({ item, draft, onDraft, denyReason, locked }) {
@@ -46,5 +46,6 @@ export declare const useChats: import("zustand").UseBoundStore<import("zustand")
46
46
  export declare function chatSink(keyOf: (chatId: string) => string): ChatSink;
47
47
  export declare function useChatRow<T>(chatId: string, select: (row: ChatState | undefined) => T): T;
48
48
  export declare function useReportedModels(provider: AgentKind): ReadonlySet<string>;
49
+ export declare function useAwaitsApproval(chatId: string, toolUseId: string): boolean;
49
50
  export declare function useCurrentItem<T extends ChatItem>(chatId: string, derived: T): T;
50
51
  export {};
@@ -243,6 +243,10 @@ export function useReportedModels(provider) {
243
243
  });
244
244
  return useMemo(() => new Set(joined === '' ? [] : joined.split('\n')), [joined]);
245
245
  }
246
+ /* Whether a call still waits on the person's answer to an approval of it, when its clock is not running yet. */
247
+ export function useAwaitsApproval(chatId, toolUseId) {
248
+ return useChatRow(chatId, (row) => waitingRequestsOf(row).some((request) => request.kind === 'approval' && request.toolUseId === toolUseId));
249
+ }
246
250
  /* The item as the thread holds it now, for a row derived from the structure, which a delta leaves alone. */
247
251
  export function useCurrentItem(chatId, derived) {
248
252
  return useChatRow(chatId, (row) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.31.10",
3
+ "version": "0.31.12",
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.31.10",
71
- "@adecore/editor": "0.31.10",
72
- "@adecore/editor-react": "0.31.10",
73
- "@adecore/intelligent-ui": "0.31.10",
74
- "@adecore/merge": "0.31.10",
70
+ "@adecore/agent-contracts": "0.31.12",
71
+ "@adecore/editor": "0.31.12",
72
+ "@adecore/editor-react": "0.31.12",
73
+ "@adecore/intelligent-ui": "0.31.12",
74
+ "@adecore/merge": "0.31.12",
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.31.10",
97
+ "@adecore/ui": "^0.31.12",
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.31.10",
104
+ "@adecore/ui": "0.31.12",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -25,7 +25,7 @@ function matches(model: ModelInfo, query: string): boolean {
25
25
  * By maker, then by name, when the provider says who made its models; a list with no makers keeps the order
26
26
  * it came in. A model with no maker goes last, so the ones that have one read as groups.
27
27
  */
28
- function byVendor(models: ModelInfo[]): ModelInfo[] {
28
+ export function modelsByVendor(models: ModelInfo[]): ModelInfo[] {
29
29
  if (!models.some((model) => model.vendor !== undefined)) {
30
30
  return models;
31
31
  }
@@ -40,6 +40,27 @@ function byVendor(models: ModelInfo[]): ModelInfo[] {
40
40
  });
41
41
  }
42
42
 
43
+ /* The models a long list shows before it is opened: the default and the one that is picked. Null when the list is short enough to show whole. */
44
+ export function shortlistOf(kind: AgentKind, models: ModelInfo[], current: PickerCurrent | null): ModelInfo[] | null {
45
+ if (models.length <= COMPACT_MODEL_LIMIT) {
46
+ return null;
47
+ }
48
+ return modelsByVendor(models.filter((model) => model.isDefault || (current?.provider === kind && current.model === model.slug)));
49
+ }
50
+
51
+ /* Whether the providers offer enough models together that a search is quicker than reading the list. */
52
+ export function wantsModelSearch(providers: readonly ProviderInfo[]): boolean {
53
+ return providers.reduce((count, entry) => count + entry.models.length, 0) > COMPACT_MODEL_LIMIT;
54
+ }
55
+
56
+ /* Every model of every provider that a search finds, legacy ones included, by maker; a provider with none is left out. */
57
+ export function searchModels(providers: readonly ProviderInfo[], query: string): Array<{ entry: ProviderInfo; models: ModelInfo[] }> {
58
+ const needle = query.trim().toLowerCase();
59
+ return providers
60
+ .map((entry) => ({ entry, models: modelsByVendor(entry.models.filter((model) => matches(model, needle))) }))
61
+ .filter((group) => group.models.length > 0);
62
+ }
63
+
43
64
  const NONE: ReadonlySet<AgentKind> = new Set();
44
65
 
45
66
  /*
@@ -55,7 +76,8 @@ export function pickerEntriesOf(
55
76
  expanded: ReadonlySet<AgentKind> = NONE,
56
77
  current: PickerCurrent | null = null
57
78
  ): PickerEntry[] {
58
- const rowsOf = (entry: ProviderInfo, models: ModelInfo[]): PickerEntry[] => byVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
79
+ const rowsOf = (entry: ProviderInfo, models: ModelInfo[]): PickerEntry[] =>
80
+ modelsByVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
59
81
  if (query !== '') {
60
82
  return providers.flatMap((entry) =>
61
83
  rowsOf(
@@ -69,13 +91,13 @@ export function pickerEntriesOf(
69
91
  );
70
92
  const live = providers.flatMap((entry): PickerEntry[] => {
71
93
  const models = entry.models.filter((model) => !model.legacy);
72
- if (models.length <= COMPACT_MODEL_LIMIT) {
94
+ const shortlist = shortlistOf(entry.kind, models, current);
95
+ if (shortlist === null) {
73
96
  return rowsOf(entry, models);
74
97
  }
75
98
  if (expanded.has(entry.kind)) {
76
99
  return [{ kind: 'all', provider: entry, count: models.length, open: true }, ...rowsOf(entry, models)];
77
100
  }
78
- const shortlist = models.filter((model) => model.isDefault || (current?.provider === entry.kind && current.model === model.slug));
79
101
  return [...rowsOf(entry, shortlist), { kind: 'all', provider: entry, count: models.length, open: false }];
80
102
  });
81
103
  if (legacy.length === 0) {
@@ -0,0 +1,25 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { Wrench } from 'lucide-react';
3
+ import type { ModelInfo } from '@adecore/agent-contracts';
4
+ import { Icon, Pill, Tooltip } from '@adecore/ui';
5
+
6
+ /* What a model row says beside the name: its badge, and a mark when the provider says the model can call tools. */
7
+ export function ModelMarks({ model }: { model: ModelInfo }) {
8
+ const { t } = useTranslation('agent-chat');
9
+ return (
10
+ <>
11
+ {model.badge && (
12
+ <Pill tone="accent" shape="tag" size="sm">
13
+ {model.badge.charAt(0).toLocaleUpperCase() + model.badge.slice(1)}
14
+ </Pill>
15
+ )}
16
+ {model.tools === true && (
17
+ <Tooltip label={t('pickers.model.tools')}>
18
+ <span className="flex shrink-0 text-text-faint" aria-label={t('pickers.model.tools')}>
19
+ <Icon icon={Wrench} size={12} />
20
+ </span>
21
+ </Tooltip>
22
+ )}
23
+ </>
24
+ );
25
+ }
@@ -9,9 +9,10 @@ import { modelRequirementOf } from '../../agents/model-requirement';
9
9
  import { useChatHost } from '../../host';
10
10
  import { ChatScopeContext } from '../../scope';
11
11
  import { useReportedModels } from '../../state/chats';
12
+ import { ModelMarks } from './ModelMarks';
12
13
  import { pickerEntriesOf, type PickerEntry } from '../logic/picker-entries';
13
14
  import { forgetStashed, STASH_SHORTCUT, useStash, type StashedPrompt } from '../stash';
14
- import { Icon, IconButton, Menu, Pill, Popover, Tooltip } from '@adecore/ui';
15
+ import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
15
16
 
16
17
  const triggerClass =
17
18
  'flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text';
@@ -257,11 +258,7 @@ export function ModelPicker({
257
258
  <span className="flex w-full items-center gap-2">
258
259
  <Menu.Check kind="radio" checked={chosen} />
259
260
  <span className="min-w-0 truncate">{entry.model.name}</span>
260
- {entry.model.badge && (
261
- <Pill tone="accent" shape="tag" size="sm">
262
- {entry.model.badge.charAt(0).toLocaleUpperCase() + entry.model.badge.slice(1)}
263
- </Pill>
264
- )}
261
+ <ModelMarks model={entry.model} />
265
262
  <span className="grow" />
266
263
  {entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
267
264
  </span>
@@ -1,3 +1,4 @@
1
+ import { Fragment, useEffect, useRef, useState, type KeyboardEvent } from 'react';
1
2
  import { useTranslation } from 'react-i18next';
2
3
  import clsx from 'clsx';
3
4
  import {
@@ -9,6 +10,7 @@ import {
9
10
  FilePen,
10
11
  Gauge,
11
12
  Hand,
13
+ Search,
12
14
  Settings,
13
15
  Shield,
14
16
  ShieldAlert,
@@ -22,10 +24,12 @@ import { canContinueOn } from '../../agents/accounts';
22
24
  import { AgentIcon } from '../../agents/AgentIcon';
23
25
  import type { AccountChoice } from '../account-choice';
24
26
  import { modelName, shortModelName } from '../../agents/model-name';
27
+ import { ModelMarks } from './ModelMarks';
28
+ import { modelsByVendor, searchModels, shortlistOf, wantsModelSearch } from '../logic/picker-entries';
25
29
  import { CONTEXT_OPTION, contextFraction, contextSegments, orderOptions, type ContextPart } from '../logic/context-usage';
26
30
  import { RUNTIME_MODES } from '../runtime-modes';
27
31
  import { formatClock, formatWeekdayClock, isSameDay, formatPercent, formatTokens } from '@adecore/ui/format';
28
- import { Icon, Menu, Pill, Tooltip, useNow } from '@adecore/ui';
32
+ import { Icon, Menu, Tooltip, useNow } from '@adecore/ui';
29
33
  import { useChatHost } from '../../host';
30
34
  import { useUsageLimits } from '../../usage/limits';
31
35
 
@@ -134,6 +138,8 @@ export function RunSettings({
134
138
  account
135
139
  }: RunSettingsProps) {
136
140
  const { t } = useTranslation('agent-chat');
141
+ const [query, setQuery] = useState('');
142
+ const searchRef = useRef<HTMLInputElement>(null);
137
143
  const owner = providers.find((entry) => entry.kind === provider);
138
144
  const current = modelName(selection.model, owner?.models);
139
145
  const short = shortModelName(current, owner?.models);
@@ -164,6 +170,100 @@ export function RunSettings({
164
170
  onModel(value.slice(0, slash) as AgentKind, value.slice(slash + 1));
165
171
  };
166
172
 
173
+ const searchable = wantsModelSearch(providers);
174
+ const found = query.trim() === '' ? null : searchModels(providers, query);
175
+
176
+ // The menu moves the focus to its popup as it opens, so the search field takes it a frame later.
177
+ useEffect(() => {
178
+ if (!open || !searchable) {
179
+ return;
180
+ }
181
+ const frame = requestAnimationFrame(() => searchRef.current?.focus());
182
+ return () => cancelAnimationFrame(frame);
183
+ }, [open, searchable]);
184
+
185
+ const changeOpen = (next: boolean): void => {
186
+ if (!next) {
187
+ setQuery('');
188
+ }
189
+ onOpenChange(next);
190
+ };
191
+
192
+ // An item the arrow keys moved to has the focus; typing there still goes to the search.
193
+ const onPopupKey = (event: KeyboardEvent<HTMLDivElement>): void => {
194
+ if (!searchable || event.target === searchRef.current || event.metaKey || event.ctrlKey || event.altKey) {
195
+ return;
196
+ }
197
+ const typed = event.key.length === 1 && event.key !== ' ';
198
+ if (!typed && event.key !== 'Backspace') {
199
+ return;
200
+ }
201
+ event.preventDefault();
202
+ event.stopPropagation();
203
+ setQuery((now) => (typed ? now + event.key : now.slice(0, -1)));
204
+ searchRef.current?.focus();
205
+ };
206
+
207
+ // The menu reads letters as a jump to an item and arrows as moving through them; the field keeps what it types.
208
+ const onSearchKey = (event: KeyboardEvent<HTMLInputElement>): void => {
209
+ if (event.key === 'Escape' && query !== '') {
210
+ event.preventDefault();
211
+ event.stopPropagation();
212
+ setQuery('');
213
+ return;
214
+ }
215
+ if (event.key === 'Enter') {
216
+ event.preventDefault();
217
+ const first = found?.[0];
218
+ if (first !== undefined) {
219
+ onModel(first.entry.kind, first.models[0]!.slug);
220
+ changeOpen(false);
221
+ }
222
+ return;
223
+ }
224
+ if (event.key !== 'Escape' && event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Tab') {
225
+ event.stopPropagation();
226
+ }
227
+ };
228
+
229
+ const modelRow = (entry: ProviderInfo, row: ModelInfo) => (
230
+ <Menu.RadioItem key={row.slug} value={modelValue(entry.kind, row.slug)} closeOnClick>
231
+ <AgentIcon kind={entry.kind} size={14} className="shrink-0" />
232
+ <span className="min-w-0 truncate">{row.name}</span>
233
+ <ModelMarks model={row} />
234
+ </Menu.RadioItem>
235
+ );
236
+
237
+ // Every model of one provider, under a label per maker where the provider names them.
238
+ const byMaker = (entry: ProviderInfo, models: ModelInfo[]) =>
239
+ modelsByVendor(models).map((row, i, sorted) => (
240
+ <Fragment key={row.slug}>
241
+ {row.vendor !== undefined && sorted[i - 1]?.vendor !== row.vendor && <Menu.GroupLabel>{row.vendor}</Menu.GroupLabel>}
242
+ {modelRow(entry, row)}
243
+ </Fragment>
244
+ ));
245
+
246
+ // A provider with many models shows the default and the chosen one, with all of them one submenu away.
247
+ const providerModels = (entry: ProviderInfo, models: ModelInfo[]) => {
248
+ const shortlist = shortlistOf(entry.kind, models, { provider, model: selection.model });
249
+ if (shortlist === null) {
250
+ return models.map((row) => modelRow(entry, row));
251
+ }
252
+ return (
253
+ <>
254
+ {shortlist.map((row) => modelRow(entry, row))}
255
+ <Menu.SubmenuRoot>
256
+ <Menu.SubmenuTrigger className="text-text-muted">{t('pickers.model.allGroup', { count: models.length })}</Menu.SubmenuTrigger>
257
+ <Menu.Popup className="w-80">
258
+ <Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
259
+ {byMaker(entry, models)}
260
+ </Menu.RadioGroup>
261
+ </Menu.Popup>
262
+ </Menu.SubmenuRoot>
263
+ </>
264
+ );
265
+ };
266
+
167
267
  const modelGroups = (groups: typeof live) =>
168
268
  groups.map(({ entry, models }) => (
169
269
  <Menu.Group key={entry.kind}>
@@ -173,22 +273,12 @@ export function RunSettings({
173
273
  {entry.name}
174
274
  </Menu.GroupLabel>
175
275
  )}
176
- {models.map((row) => (
177
- <Menu.RadioItem key={row.slug} value={modelValue(entry.kind, row.slug)} closeOnClick>
178
- <AgentIcon kind={entry.kind} size={14} className="shrink-0" />
179
- <span className="min-w-0 truncate">{row.name}</span>
180
- {row.badge && (
181
- <Pill tone="accent" shape="tag" size="sm">
182
- {row.badge.charAt(0).toLocaleUpperCase() + row.badge.slice(1)}
183
- </Pill>
184
- )}
185
- </Menu.RadioItem>
186
- ))}
276
+ {providerModels(entry, models)}
187
277
  </Menu.Group>
188
278
  ));
189
279
 
190
280
  return (
191
- <Menu.Root open={open} onOpenChange={onOpenChange}>
281
+ <Menu.Root open={open} onOpenChange={changeOpen} highlightItemOnHover={!searchable}>
192
282
  <Tooltip label={permissionHint ?? t('pickers.settings.title')} kbd="/model">
193
283
  {/* Styled from `open`: the tooltip puts `data-popup-open` on the same trigger. */}
194
284
  <Menu.Trigger
@@ -233,63 +323,102 @@ export function RunSettings({
233
323
  <Icon icon={open ? ChevronUp : ChevronDown} size={12} className="shrink-0 @max-sm/composer:hidden" />
234
324
  </Menu.Trigger>
235
325
  </Tooltip>
236
- <Menu.Popup side="top" sideOffset={8} align="start" className="w-80">
326
+ <Menu.Popup side="top" sideOffset={8} align="start" className="w-80" onKeyDownCapture={onPopupKey}>
237
327
  {providers.length === 0 && <div className="px-2.5 py-3 text-xs text-text-faint">{t('pickers.model.noProvider')}</div>}
238
- <Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
239
- {modelGroups(live)}
240
- </Menu.RadioGroup>
241
- {legacy.length > 0 && (
242
- <Menu.SubmenuRoot>
243
- <Menu.SubmenuTrigger className="text-text-muted">
244
- <span className="min-w-0 grow truncate">
245
- {t('pickers.model.legacyGroup', { count: legacy.reduce((count, group) => count + group.models.length, 0) })}
246
- </span>
247
- {chosenLegacy && <Menu.Hint className="truncate">{chosenLegacy.name}</Menu.Hint>}
248
- </Menu.SubmenuTrigger>
249
- <Menu.Popup className="min-w-56">
250
- <Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
251
- {modelGroups(legacy)}
252
- </Menu.RadioGroup>
253
- </Menu.Popup>
254
- </Menu.SubmenuRoot>
328
+ {searchable && (
329
+ <div className="flex items-center gap-2 border-b border-border px-2.5">
330
+ <Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
331
+ <input
332
+ ref={searchRef}
333
+ className="h-8 w-full bg-transparent text-sm text-text outline-none placeholder:text-text-faint"
334
+ placeholder={t('pickers.model.search')}
335
+ aria-label={t('pickers.model.search')}
336
+ spellCheck={false}
337
+ value={query}
338
+ onChange={(event) => setQuery(event.target.value)}
339
+ onKeyDown={onSearchKey}
340
+ />
341
+ </div>
255
342
  )}
256
- <Menu.Separator />
257
- {account !== null && <AccountSubmenu provider={provider} account={account} />}
258
- <Menu.SubmenuRoot>
259
- <Menu.SubmenuTrigger className="text-text-muted">
260
- <Icon icon={MODE_ICONS[runtimeMode]} size={14} className={clsx('shrink-0', fullAccess ? 'text-status-needs-you' : 'text-text-faint')} />
261
- <span className="min-w-0 grow truncate">{t('pickers.mode.label')}</span>
262
- <Menu.Hint className="truncate">{t(`modes.${runtimeMode}.label`)}</Menu.Hint>
263
- </Menu.SubmenuTrigger>
264
- <Menu.Popup className="w-72">
265
- {permissionHint !== null && <p className="px-2.5 py-2 text-xs text-text-muted">{permissionHint}</p>}
266
- <Menu.RadioGroup value={runtimeMode} onValueChange={(mode: RuntimeMode) => onMode(mode)}>
267
- {RUNTIME_MODES.map((mode) => (
268
- <Menu.RadioItem key={mode} value={mode} className="items-start">
269
- {/* The hint makes the row two lines high; the box keeps the icon on the label's line. */}
270
- <span className="grid h-lh w-4 shrink-0 place-items-center">
271
- <Icon
272
- icon={MODE_ICONS[mode]}
273
- size={14}
274
- className={mode === 'full-access' ? 'text-status-needs-you' : 'text-text-faint'}
275
- />
276
- </span>
277
- <span className="flex min-w-0 grow flex-col">
278
- {t(`modes.${mode}.label`)}
279
- <span className="text-xs text-text-faint">{t(`modes.${mode}.hint`)}</span>
280
- </span>
281
- </Menu.RadioItem>
282
- ))}
283
- </Menu.RadioGroup>
284
- </Menu.Popup>
285
- </Menu.SubmenuRoot>
286
- {options.map((option) => (
287
- <OptionRow key={option.id} option={option} selection={selection} onChange={(value) => onOption(option.id, value)} />
288
- ))}
289
- {(hasContextOption || standaloneContext) && (
343
+ {found !== null && found.length === 0 && <div className="px-2.5 py-3 text-xs text-text-faint">{t('pickers.model.noMatch')}</div>}
344
+ {found !== null && found.length > 0 && (
345
+ <Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
346
+ {found.map(({ entry, models }) => (
347
+ <Menu.Group key={entry.kind}>
348
+ {grouped && (
349
+ <Menu.GroupLabel className="flex items-center gap-1.5">
350
+ <AgentIcon kind={entry.kind} size={12} />
351
+ {entry.name}
352
+ </Menu.GroupLabel>
353
+ )}
354
+ {byMaker(entry, models)}
355
+ </Menu.Group>
356
+ ))}
357
+ </Menu.RadioGroup>
358
+ )}
359
+ {found === null && (
290
360
  <>
361
+ <Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
362
+ {modelGroups(live)}
363
+ </Menu.RadioGroup>
364
+ {legacy.length > 0 && (
365
+ <Menu.SubmenuRoot>
366
+ <Menu.SubmenuTrigger className="text-text-muted">
367
+ <span className="min-w-0 grow truncate">
368
+ {t('pickers.model.legacyGroup', { count: legacy.reduce((count, group) => count + group.models.length, 0) })}
369
+ </span>
370
+ {chosenLegacy && <Menu.Hint className="truncate">{chosenLegacy.name}</Menu.Hint>}
371
+ </Menu.SubmenuTrigger>
372
+ <Menu.Popup className="min-w-56">
373
+ <Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
374
+ {modelGroups(legacy)}
375
+ </Menu.RadioGroup>
376
+ </Menu.Popup>
377
+ </Menu.SubmenuRoot>
378
+ )}
291
379
  <Menu.Separator />
292
- <ContextUsage usage={usage} disabled={compactDisabled} onCompact={onCompact} />
380
+ {account !== null && <AccountSubmenu provider={provider} account={account} />}
381
+ <Menu.SubmenuRoot>
382
+ <Menu.SubmenuTrigger className="text-text-muted">
383
+ <Icon
384
+ icon={MODE_ICONS[runtimeMode]}
385
+ size={14}
386
+ className={clsx('shrink-0', fullAccess ? 'text-status-needs-you' : 'text-text-faint')}
387
+ />
388
+ <span className="min-w-0 grow truncate">{t('pickers.mode.label')}</span>
389
+ <Menu.Hint className="truncate">{t(`modes.${runtimeMode}.label`)}</Menu.Hint>
390
+ </Menu.SubmenuTrigger>
391
+ <Menu.Popup className="w-72">
392
+ {permissionHint !== null && <p className="px-2.5 py-2 text-xs text-text-muted">{permissionHint}</p>}
393
+ <Menu.RadioGroup value={runtimeMode} onValueChange={(mode: RuntimeMode) => onMode(mode)}>
394
+ {RUNTIME_MODES.map((mode) => (
395
+ <Menu.RadioItem key={mode} value={mode} className="items-start">
396
+ {/* The hint makes the row two lines high; the box keeps the icon on the label's line. */}
397
+ <span className="grid h-lh w-4 shrink-0 place-items-center">
398
+ <Icon
399
+ icon={MODE_ICONS[mode]}
400
+ size={14}
401
+ className={mode === 'full-access' ? 'text-status-needs-you' : 'text-text-faint'}
402
+ />
403
+ </span>
404
+ <span className="flex min-w-0 grow flex-col">
405
+ {t(`modes.${mode}.label`)}
406
+ <span className="text-xs text-text-faint">{t(`modes.${mode}.hint`)}</span>
407
+ </span>
408
+ </Menu.RadioItem>
409
+ ))}
410
+ </Menu.RadioGroup>
411
+ </Menu.Popup>
412
+ </Menu.SubmenuRoot>
413
+ {options.map((option) => (
414
+ <OptionRow key={option.id} option={option} selection={selection} onChange={(value) => onOption(option.id, value)} />
415
+ ))}
416
+ {(hasContextOption || standaloneContext) && (
417
+ <>
418
+ <Menu.Separator />
419
+ <ContextUsage usage={usage} disabled={compactDisabled} onCompact={onCompact} />
420
+ </>
421
+ )}
293
422
  </>
294
423
  )}
295
424
  </Menu.Popup>
@@ -26,7 +26,7 @@ import { useChatHost, type CommandLook } from '../../../host';
26
26
  import { lazyNamed } from '../../../lazy';
27
27
  import { useChatActions } from '../../actions';
28
28
  import { ChatScopeContext, useChatScope } from '../../../scope';
29
- import { useCurrentItem } from '../../../state/chats';
29
+ import { useAwaitsApproval, useCurrentItem } from '../../../state/chats';
30
30
  import { useTimelineFlash } from '../../timeline-flash';
31
31
  import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
32
32
  import { Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
@@ -378,6 +378,8 @@ export function WorkLiveRow({ chatId, tool: derived }: { chatId: string; tool: C
378
378
  const shell = shellCommand(tool.name, tool.input);
379
379
  const { commandLooks } = useChatHost();
380
380
  const run = shell === null ? null : matchCommandLook(shell, commandLooks);
381
+ const awaiting = useAwaitsApproval(chatId, tool.toolUseId);
382
+ const { t } = useTranslation('agent-chat');
381
383
  return (
382
384
  <div>
383
385
  <ToggleLine
@@ -390,7 +392,13 @@ export function WorkLiveRow({ chatId, tool: derived }: { chatId: string; tool: C
390
392
  path={summary !== '' && toolSummaryIsPath(tool.name, tool.input)}
391
393
  open={open && opens}
392
394
  onToggle={opens ? () => setOpen((o) => !o) : undefined}
393
- trailing={<RunningFor startedAt={toolStartedAt(tool)} />}
395
+ trailing={
396
+ awaiting ? (
397
+ <span className="shrink-0 text-xs text-text-faint">{t('work.awaitingApproval')}</span>
398
+ ) : (
399
+ <RunningFor startedAt={toolStartedAt(tool)} />
400
+ )
401
+ }
394
402
  live
395
403
  />
396
404
  {open && opens && <ToolBody tool={tool} />}
@@ -183,6 +183,7 @@ export default {
183
183
  loadingDiff: 'Loading diff',
184
184
  diffUnavailable: 'Could not load this diff',
185
185
  runningFor: 'running for {{elapsed}}',
186
+ awaitingApproval: 'waiting for you',
186
187
  forksAfter_one: 'A fork goes on after this turn',
187
188
  forksAfter_other: '{{count}} forks go on after this turn',
188
189
  omitted: {
@@ -439,6 +440,7 @@ export default {
439
440
  legacyGroup_one: 'Legacy models ({{count}})',
440
441
  legacyGroup_other: 'Legacy models ({{count}})',
441
442
  legacy: 'Legacy',
443
+ tools: 'Can use tools',
442
444
  requires: 'Requires {{cli}} {{version}} or later on {{machine}}',
443
445
  requiresHere: 'Requires {{cli}} {{version}} or later',
444
446
  providerSettings: 'Provider settings'
@@ -183,6 +183,7 @@ export default {
183
183
  loadingDiff: 'Diff laden',
184
184
  diffUnavailable: 'Deze diff kon niet worden geladen',
185
185
  runningFor: 'loopt al {{elapsed}}',
186
+ awaitingApproval: 'wacht op jou',
186
187
  forksAfter_one: 'Een fork gaat verder na deze turn',
187
188
  forksAfter_other: '{{count}} forks gaan verder na deze turn',
188
189
  omitted: {
@@ -439,6 +440,7 @@ export default {
439
440
  legacyGroup_one: 'Legacy-modellen ({{count}})',
440
441
  legacyGroup_other: 'Legacy-modellen ({{count}})',
441
442
  legacy: 'Legacy',
443
+ tools: 'Kan tools gebruiken',
442
444
  requires: 'Vereist {{cli}} {{version}} of nieuwer op {{machine}}',
443
445
  requiresHere: 'Vereist {{cli}} {{version}} of nieuwer',
444
446
  providerSettings: 'Providerinstellingen'
@@ -30,9 +30,11 @@ function ApprovalDetails({ item }: { item: ChatApprovalItem }) {
30
30
  const editorPreview = host.code.editor !== undefined;
31
31
  const maxHeight = expanded ? 480 : 176;
32
32
  const input = typeof item.input === 'object' && item.input !== null ? (item.input as Record<string, unknown>) : {};
33
+ // Some agents describe a command by the command itself, which the box under it already shows.
34
+ const describes = item.description && item.description.trim() !== (typeof input.command === 'string' ? input.command.trim() : null);
33
35
  return (
34
36
  <>
35
- {item.description && <p className="text-sm text-text-muted">{item.description}</p>}
37
+ {describes && <p className="text-sm text-text-muted">{item.description}</p>}
36
38
  {patches.length + edits.length > 1 && (
37
39
  <p className="text-xs text-text-muted">{t('approval.allChanges', { count: patches.length + edits.length })}</p>
38
40
  )}
@@ -347,6 +347,11 @@ export function useReportedModels(provider: AgentKind): ReadonlySet<string> {
347
347
  return useMemo(() => new Set(joined === '' ? [] : joined.split('\n')), [joined]);
348
348
  }
349
349
 
350
+ /* Whether a call still waits on the person's answer to an approval of it, when its clock is not running yet. */
351
+ export function useAwaitsApproval(chatId: string, toolUseId: string): boolean {
352
+ return useChatRow(chatId, (row) => waitingRequestsOf(row).some((request) => request.kind === 'approval' && request.toolUseId === toolUseId));
353
+ }
354
+
350
355
  /* The item as the thread holds it now, for a row derived from the structure, which a delta leaves alone. */
351
356
  export function useCurrentItem<T extends ChatItem>(chatId: string, derived: T): T {
352
357
  return useChatRow(chatId, (row) => {