@adecore/agents-react 0.29.3 → 0.31.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.
Files changed (52) hide show
  1. package/dist/agents/AgentIcon.js +4 -3
  2. package/dist/agents/accounts.d.ts +2 -0
  3. package/dist/agents/accounts.js +6 -0
  4. package/dist/agents/agent-marks.d.ts +8 -0
  5. package/dist/agents/agent-marks.js +12 -0
  6. package/dist/agents/provider-paths.js +3 -2
  7. package/dist/chat/logic/picker-entries.d.ts +12 -2
  8. package/dist/chat/logic/picker-entries.js +41 -7
  9. package/dist/chat/ui/Pickers.js +31 -3
  10. package/dist/chat/ui/rows/row-rhythm.js +3 -2
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/locales/en/agent-chat.d.ts +2 -0
  14. package/dist/locales/en/agent-chat.js +2 -0
  15. package/dist/locales/en/agent-providers.d.ts +3 -0
  16. package/dist/locales/en/agent-providers.js +4 -1
  17. package/dist/locales/nl/agent-chat.d.ts +2 -0
  18. package/dist/locales/nl/agent-chat.js +2 -0
  19. package/dist/locales/nl/agent-providers.d.ts +3 -0
  20. package/dist/locales/nl/agent-providers.js +4 -1
  21. package/dist/providers/AccountDetail.d.ts +4 -3
  22. package/dist/providers/AccountDetail.js +6 -5
  23. package/dist/providers/AddAccountForm.d.ts +3 -2
  24. package/dist/providers/AddAccountForm.js +2 -2
  25. package/dist/providers/CliDetail.d.ts +3 -3
  26. package/dist/providers/CliDetail.js +3 -3
  27. package/dist/providers/ProvidersPane.js +11 -10
  28. package/dist/providers/parts.js +5 -4
  29. package/dist/theme.css +3 -0
  30. package/dist/usage/format.js +6 -2
  31. package/dist/usage/limit-groups.js +2 -1
  32. package/package.json +8 -8
  33. package/src/agents/AgentIcon.tsx +12 -3
  34. package/src/agents/accounts.ts +13 -0
  35. package/src/agents/agent-marks.ts +26 -0
  36. package/src/agents/provider-paths.ts +3 -2
  37. package/src/chat/logic/picker-entries.ts +70 -10
  38. package/src/chat/ui/Pickers.tsx +54 -2
  39. package/src/chat/ui/rows/row-rhythm.ts +3 -2
  40. package/src/index.ts +1 -0
  41. package/src/locales/en/agent-chat.ts +2 -0
  42. package/src/locales/en/agent-providers.ts +4 -1
  43. package/src/locales/nl/agent-chat.ts +2 -0
  44. package/src/locales/nl/agent-providers.ts +4 -1
  45. package/src/providers/AccountDetail.tsx +34 -26
  46. package/src/providers/AddAccountForm.tsx +8 -3
  47. package/src/providers/CliDetail.tsx +5 -5
  48. package/src/providers/ProvidersPane.tsx +13 -10
  49. package/src/providers/parts.tsx +6 -5
  50. package/src/theme.css +3 -0
  51. package/src/usage/format.ts +6 -2
  52. package/src/usage/limit-groups.ts +2 -1
@@ -4,7 +4,7 @@ import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { Plus, Terminal } from 'lucide-react';
6
6
  import { AccountDot } from '../agents/AccountDot.js';
7
- import { ACCOUNT_TONE_CLASSES, accountName, accountsOfKind, accountStatusLine } from '../agents/accounts.js';
7
+ import { ACCOUNT_TONE_CLASSES, accountLoginOf, accountName, accountsOfKind, accountStatusLine } from '../agents/accounts.js';
8
8
  import { ErrorBoundary, Icon, IconButton, SectionLabel, Skeleton } from '@adecore/ui';
9
9
  import { MasterDetail } from '@adecore/ui/settings';
10
10
  import { useChatHost } from '../host.js';
@@ -30,10 +30,11 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }) {
30
30
  const { openLogin } = host;
31
31
  const loginBlocked = host.useLoginBlocked(scopeId);
32
32
  const [picked, setPicked] = useState(() => (target === 'providers.apple' ? { kind: 'cli', cli: 'apple' } : null));
33
- const installed = useMemo(() => providers.filter((provider) => provider.installed || provider.kind === 'apple'), [providers]);
34
- const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.kind !== 'apple'), [providers]);
33
+ // A provider that never runs in a terminal is no CLI to install: it is turned on in its own detail, so it is always listed.
34
+ const installed = useMemo(() => providers.filter((provider) => provider.installed || !provider.capabilities.terminal), [providers]);
35
+ const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.capabilities.terminal), [providers]);
35
36
  const entriesOf = (kind) => accountsOfKind(accounts, kind);
36
- const canAddAccount = (kind) => accounts?.loginCommands?.[kind] !== undefined;
37
+ const loginOf = (kind) => accountLoginOf(accounts, kind);
37
38
  // What was picked may be gone since (an account removed elsewhere); the first CLI stands in for it.
38
39
  const pickedEntry = picked?.kind === 'account' ? (Object.hasOwn(accounts?.accounts ?? {}, picked.id) ? picked.id : null) : null;
39
40
  const current = picked !== null && (picked.kind !== 'account' || pickedEntry !== null) ? picked : installed[0] ? { kind: 'cli', cli: installed[0].kind } : null;
@@ -53,15 +54,15 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }) {
53
54
  }
54
55
  const added = (id, name, made) => {
55
56
  setPicked({ kind: 'account', id });
56
- if (made && provider !== null && openLogin !== null && loginBlocked === null) {
57
+ if (made && provider !== null && loginOf(provider.kind) === 'terminal' && openLogin !== null && loginBlocked === null) {
57
58
  void openLogin(scopeId, provider.kind, id, name);
58
59
  }
59
60
  };
60
61
  const group = (cli) => {
61
62
  const entries = cli.installed ? entriesOf(cli.kind) : [];
62
- return (_jsxs("div", { className: "flex min-w-0 shrink-0 flex-col gap-0.5 pt-1", children: [_jsxs(ListRow, { selected: current?.kind === 'cli' && current.cli === cli.kind, onSelect: () => setPicked({ kind: 'cli', cli: cli.kind }), className: "pr-1", trailing: canAddAccount(cli.kind) && (_jsx(IconButton, { icon: Plus, size: "sm", label: t('list.addAccount', { provider: cli.name }), onClick: () => setPicked({ kind: 'add', cli: cli.kind }) })), children: [_jsx(CliMark, { kind: cli.kind }), _jsx("span", { className: "min-w-0 grow truncate text-xs font-medium text-text", children: cli.name }), cli.kind !== 'apple' && cli.version && _jsx("span", { className: "shrink-0 font-mono text-code text-text-faint", children: cli.version })] }), entries.map((entry) => {
63
+ return (_jsxs("div", { className: "flex min-w-0 shrink-0 flex-col gap-0.5 pt-1", children: [_jsxs(ListRow, { selected: current?.kind === 'cli' && current.cli === cli.kind, onSelect: () => setPicked({ kind: 'cli', cli: cli.kind }), className: "pr-1", trailing: loginOf(cli.kind) !== null && (_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
- const status = accountStatusLine(entry.status, canAddAccount(cli.kind));
65
+ const status = accountStatusLine(entry.status, loginOf(cli.kind) === 'terminal');
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));
66
67
  })] }, cli.kind));
67
68
  };
@@ -75,13 +76,13 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }) {
75
76
  return own;
76
77
  }
77
78
  if (current.kind === 'add') {
78
- return (_jsx(AddAccountForm, { provider: provider, entries: entriesOf(provider.kind), onCancel: () => setPicked({ kind: 'cli', cli: provider.kind }), onAdded: added }, provider.kind));
79
+ return (_jsx(AddAccountForm, { provider: provider, entries: entriesOf(provider.kind), login: loginOf(provider.kind), onCancel: () => setPicked({ kind: 'cli', cli: provider.kind }), onAdded: added }, provider.kind));
79
80
  }
80
81
  if (current.kind === 'account') {
81
82
  const entry = entriesOf(provider.kind).find((candidate) => candidate.id === current.id);
82
- return (entry && (_jsx(AccountDetail, { provider: provider, entry: entry, canLogIn: openLogin !== null && canAddAccount(provider.kind), loginBlocked: loginBlocked, secretsAvailable: accounts?.secretsAvailable === true, onRemoved: () => setPicked({ kind: 'cli', cli: provider.kind }) }, entry.id)));
83
+ return (entry && (_jsx(AccountDetail, { provider: provider, entry: entry, login: loginOf(provider.kind), canOpenLogin: openLogin !== null, loginBlocked: loginBlocked, secretsAvailable: accounts?.secretsAvailable === true, onRemoved: () => setPicked({ kind: 'cli', cli: provider.kind }) }, entry.id)));
83
84
  }
84
- return (_jsx(CliDetail, { provider: provider, entries: accounts === null ? null : entriesOf(provider.kind), defaults: defaults, canAddAccount: canAddAccount(provider.kind), onAddAccount: () => setPicked({ kind: 'add', cli: provider.kind }), onPickAccount: (id) => setPicked({ kind: 'account', id }) }));
85
+ return (_jsx(CliDetail, { provider: provider, entries: accounts === null ? null : entriesOf(provider.kind), defaults: defaults, login: loginOf(provider.kind), onAddAccount: () => setPicked({ kind: 'add', cli: provider.kind }), onPickAccount: (id) => setPicked({ kind: 'account', id }) }));
85
86
  };
86
87
  return (_jsx(MasterDetail, { listWidth: 340, listLabel: t('list.label'), list: list, detail: _jsx(ErrorBoundary, { label: t('failed'), resetKeys: [scopeId, current?.kind, currentCli], children: _jsx("div", { className: "flex min-w-0 flex-col gap-7", children: detail() }) }) }));
87
88
  }
@@ -1,16 +1,17 @@
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
- copilot: 'text-chart-copilot'
10
+ copilot: 'text-chart-copilot',
11
+ nanogpt: 'text-chart-nanogpt'
11
12
  };
12
13
  export function CliMark({ kind, size = 14, className }) {
13
- return _jsx(AgentIcon, { kind: kind, size: size, className: clsx('shrink-0', CLI_COLORS[kind], className) });
14
+ return _jsx(AgentIcon, { kind: kind, size: size, className: clsx('shrink-0', CLI_COLORS[kind] ?? 'text-text-muted', className) });
14
15
  }
15
16
  /* As tall as the title's first line, so the mark sits beside the name rather than between the two lines. */
16
17
  export function CliTile({ kind }) {
package/dist/theme.css CHANGED
@@ -13,6 +13,7 @@
13
13
  --chart-codex: #30b0c7;
14
14
  --chart-gemini: #3b82f6;
15
15
  --chart-copilot: #64748b;
16
+ --chart-nanogpt: #34c759;
16
17
  /* What a chat's context holds, one color per part; the rest is the system's and stays gray. */
17
18
  --chart-context-tools: #4d7ce6;
18
19
  --chart-context-files: #9a63e0;
@@ -45,6 +46,7 @@
45
46
  --chart-codex: #40c8e0;
46
47
  --chart-gemini: #60a5fa;
47
48
  --chart-copilot: #94a3b8;
49
+ --chart-nanogpt: #30d158;
48
50
  --chart-context-tools: #8aa7f5;
49
51
  --chart-context-files: #b98af3;
50
52
  --chart-context-conversation: #6dd08b;
@@ -82,6 +84,7 @@
82
84
  --color-chart-codex: var(--chart-codex);
83
85
  --color-chart-gemini: var(--chart-gemini);
84
86
  --color-chart-copilot: var(--chart-copilot);
87
+ --color-chart-nanogpt: var(--chart-nanogpt);
85
88
  --color-chart-context-tools: var(--chart-context-tools);
86
89
  --color-chart-context-files: var(--chart-context-files);
87
90
  --color-chart-context-conversation: var(--chart-context-conversation);
@@ -20,8 +20,12 @@ export function moneyFormat(currency, rate) {
20
20
  return formatMoney(value, code, value !== 0 && Math.abs(value) < 0.01 ? 4 : undefined);
21
21
  };
22
22
  }
23
- export const PROVIDER_LABELS = { claude: 'Claude Code', codex: 'Codex' };
24
- export const PROVIDER_COLORS = { claude: 'var(--chart-claude)', codex: 'var(--chart-codex)' };
23
+ export const PROVIDER_LABELS = { claude: 'Claude Code', codex: 'Codex', nanogpt: 'NanoGPT' };
24
+ export const PROVIDER_COLORS = {
25
+ claude: 'var(--chart-claude)',
26
+ codex: 'var(--chart-codex)',
27
+ nanogpt: 'var(--chart-nanogpt)'
28
+ };
25
29
  /*
26
30
  * A slot is `2026-09-10` or `2026-09-10T14`, already in the viewer's own zone, so it is read as plain
27
31
  * wall clock and never handed to a parser that would shift it back.
@@ -76,7 +76,8 @@ export function explain(provider) {
76
76
  return i18next.t('agent-usage:limits.notInstalled');
77
77
  }
78
78
  if (provider.unavailable.reason === 'no-subscription') {
79
- return i18next.t('agent-usage:limits.noSubscription');
79
+ // A provider that knows why it has no plan says so; the rest are accounts on an API key.
80
+ return provider.unavailable.message ?? i18next.t('agent-usage:limits.noSubscription');
80
81
  }
81
82
  return provider.unavailable.message ?? i18next.t('agent-usage:limits.unreachable');
82
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.29.3",
3
+ "version": "0.31.0",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.29.3",
71
- "@adecore/editor": "0.29.3",
72
- "@adecore/editor-react": "0.29.3",
73
- "@adecore/intelligent-ui": "0.29.3",
74
- "@adecore/merge": "0.29.3",
70
+ "@adecore/agent-contracts": "0.31.0",
71
+ "@adecore/editor": "0.31.0",
72
+ "@adecore/editor-react": "0.31.0",
73
+ "@adecore/intelligent-ui": "0.31.0",
74
+ "@adecore/merge": "0.31.0",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.29.3",
97
+ "@adecore/ui": "^0.31.0",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.29.3",
104
+ "@adecore/ui": "0.31.0",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -3,10 +3,11 @@ import clsx from 'clsx';
3
3
  import { Bot } from 'lucide-react';
4
4
  import { siClaude, siGithubcopilot, siGooglegemini } from 'simple-icons';
5
5
  import { Icon } from '@adecore/ui';
6
+ import { type AgentMark, hostAgentMark } from './agent-marks';
6
7
  import { PROVIDER_PATHS } from './provider-paths';
7
8
 
8
9
  // simple-icons removed OpenAI's mark at its request, so Codex reuses the usage page's path.
9
- const MARKS: Partial<Record<AgentKind, { title: string; path: string }>> = {
10
+ const BUILT_IN_MARKS: Partial<Record<AgentKind, AgentMark>> = {
10
11
  claude: siClaude,
11
12
  codex: { title: 'Codex', path: PROVIDER_PATHS.codex },
12
13
  gemini: siGooglegemini,
@@ -14,12 +15,20 @@ const MARKS: Partial<Record<AgentKind, { title: string; path: string }>> = {
14
15
  };
15
16
 
16
17
  export function AgentIcon({ kind, size = 14, className }: { kind: AgentKind; size?: number; className?: string }) {
17
- const mark = MARKS[kind];
18
+ const mark = hostAgentMark(kind) ?? BUILT_IN_MARKS[kind];
18
19
  if (!mark) {
19
20
  return <Icon icon={Bot} size={size} className={className} />;
20
21
  }
21
22
  return (
22
- <svg role="img" aria-label={mark.title} viewBox="0 0 24 24" width={size} height={size} fill="currentColor" className={clsx('align-middle', className)}>
23
+ <svg
24
+ role="img"
25
+ aria-label={mark.title}
26
+ viewBox={mark.viewBox ?? '0 0 24 24'}
27
+ width={size}
28
+ height={size}
29
+ fill="currentColor"
30
+ className={clsx('align-middle', className)}
31
+ >
23
32
  <path d={mark.path} />
24
33
  </svg>
25
34
  );
@@ -41,6 +41,19 @@ export function accountName(entry: Pick<AccountEntry, 'id' | 'account' | 'isDefa
41
41
  return entry.account.label ?? (entry.isDefault ? providerName : entry.id);
42
42
  }
43
43
 
44
+ /*
45
+ * Where an account of a kind signs in: a terminal runs the CLI's login, or its first chat asks. Null for a
46
+ * CLI without a config folder, which has only its default account; also null until the accounts arrived.
47
+ */
48
+ export type AccountLogin = 'terminal' | 'chat' | null;
49
+
50
+ export function accountLoginOf(accounts: ProviderAccounts | null | undefined, kind: AgentKind): AccountLogin {
51
+ if (accounts?.loginCommands?.[kind] !== undefined) {
52
+ return 'terminal';
53
+ }
54
+ return accounts?.chatLogins?.includes(kind) === true ? 'chat' : null;
55
+ }
56
+
44
57
  export type AccountTone = 'muted' | 'needs-you' | 'error';
45
58
 
46
59
  export const ACCOUNT_TONE_CLASSES: Record<AccountTone, string> = {
@@ -0,0 +1,26 @@
1
+ import type { AgentKind } from '@adecore/agent-contracts';
2
+
3
+ /* A CLI's mark, drawn in the current text color. */
4
+ export interface AgentMark {
5
+ /* Read by a screen reader. */
6
+ title: string;
7
+ /* The `d` of one SVG path; several shapes go in one path. */
8
+ path: string;
9
+ /* `0 0 24 24` when absent. */
10
+ viewBox?: string;
11
+ }
12
+
13
+ let hostMarks: Readonly<Partial<Record<AgentKind, AgentMark>>> = {};
14
+
15
+ /*
16
+ * The marks of the kinds a host adds, and any it draws differently, set once before the first render. Every
17
+ * place the shared UI draws an agent's mark reads them; a kind with none keeps the built-in mark or the robot.
18
+ * A later call replaces the whole set.
19
+ */
20
+ export function configureAgentMarks(marks: Readonly<Partial<Record<AgentKind, AgentMark>>>): void {
21
+ hostMarks = { ...marks };
22
+ }
23
+
24
+ export function hostAgentMark(kind: AgentKind): AgentMark | undefined {
25
+ return hostMarks[kind];
26
+ }
@@ -1,10 +1,11 @@
1
1
  import type { UsageProvider } from '@adecore/agent-contracts';
2
2
 
3
3
  /*
4
- * The marks of Anthropic and OpenAI. Lucide has no brand marks, so these are the icon rule's
4
+ * The marks of Anthropic and OpenAI, and a monogram for a provider with no mark of its own. Lucide has no brand marks, so these are the icon rule's
5
5
  * exception for provider marks. One path each, in `currentColor`, so a row decides how loud the logo is.
6
6
  */
7
7
  export const PROVIDER_PATHS: Record<UsageProvider, string> = {
8
8
  claude: 'M4.709 15.955l4.72-2.647.079-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.377-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.048.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.607.212-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.914-1.316-.012.01z',
9
- codex: 'M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.554 5.554 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.019.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.001zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z'
9
+ codex: 'M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.554 5.554 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.019.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.001zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z',
10
+ nanogpt: 'M5 20V4h2.6l8.8 11.2V4H19v16h-2.6L7.6 8.8V20z'
10
11
  };
@@ -1,23 +1,83 @@
1
- import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
1
+ import type { AgentKind, ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
2
2
 
3
- /* A model row, or the one row that folds the legacy models open. Both take a turn in the arrow keys. */
4
- export type PickerEntry = { kind: 'model'; provider: ProviderInfo; model: ModelInfo } | { kind: 'legacy'; count: number };
3
+ /*
4
+ * A provider with more live models than this keeps the list short: the model that is picked and the default,
5
+ * with the rest behind one row that opens all of them. A list of three hundred models is not one to scroll.
6
+ */
7
+ export const COMPACT_MODEL_LIMIT = 8;
8
+
9
+ /* A model row, or a row that folds more models open: one provider's models, or the legacy ones. All of them take a turn in the arrow keys. */
10
+ export type PickerEntry =
11
+ | { kind: 'model'; provider: ProviderInfo; model: ModelInfo }
12
+ | { kind: 'legacy'; count: number }
13
+ | { kind: 'all'; provider: ProviderInfo; count: number; open: boolean };
14
+
15
+ export interface PickerCurrent {
16
+ provider: AgentKind;
17
+ model: string;
18
+ }
5
19
 
6
20
  function matches(model: ModelInfo, query: string): boolean {
7
- return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
21
+ return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query) || (model.vendor?.toLowerCase().includes(query) ?? false);
8
22
  }
9
23
 
24
+ /*
25
+ * By maker, then by name, when the provider says who made its models; a list with no makers keeps the order
26
+ * it came in. A model with no maker goes last, so the ones that have one read as groups.
27
+ */
28
+ function byVendor(models: ModelInfo[]): ModelInfo[] {
29
+ if (!models.some((model) => model.vendor !== undefined)) {
30
+ return models;
31
+ }
32
+ return [...models].sort((a, b) => {
33
+ if (a.vendor === undefined || b.vendor === undefined) {
34
+ return a.vendor === b.vendor ? 0 : a.vendor === undefined ? 1 : -1;
35
+ }
36
+ return (
37
+ a.vendor.localeCompare(b.vendor, undefined, { sensitivity: 'base' }) ||
38
+ a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true })
39
+ );
40
+ });
41
+ }
42
+
43
+ const NONE: ReadonlySet<AgentKind> = new Set();
44
+
10
45
  /*
11
46
  * The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
12
- * only that model still finds the row that explains why it cannot be picked.
47
+ * only that model still finds the row that explains why it cannot be picked. A search looks through
48
+ * every model of every provider; without one, a provider with many models shows a short list and a row
49
+ * that opens the rest, grouped by maker.
13
50
  */
14
- export function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean): PickerEntry[] {
15
- const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model' as const, provider: entry, model })));
51
+ export function pickerEntriesOf(
52
+ providers: readonly ProviderInfo[],
53
+ query: string,
54
+ legacyOpen: boolean,
55
+ expanded: ReadonlySet<AgentKind> = NONE,
56
+ current: PickerCurrent | null = null
57
+ ): PickerEntry[] {
58
+ const rowsOf = (entry: ProviderInfo, models: ModelInfo[]): PickerEntry[] => byVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
16
59
  if (query !== '') {
17
- return rows.filter((row) => matches(row.model, query));
60
+ return providers.flatMap((entry) =>
61
+ rowsOf(
62
+ entry,
63
+ entry.models.filter((model) => matches(model, query))
64
+ )
65
+ );
18
66
  }
19
- const live = rows.filter((row) => !row.model.legacy);
20
- const legacy = rows.filter((row) => row.model.legacy);
67
+ const legacy = providers.flatMap((entry) =>
68
+ entry.models.filter((model) => model.legacy).map((model) => ({ kind: 'model' as const, provider: entry, model }))
69
+ );
70
+ const live = providers.flatMap((entry): PickerEntry[] => {
71
+ const models = entry.models.filter((model) => !model.legacy);
72
+ if (models.length <= COMPACT_MODEL_LIMIT) {
73
+ return rowsOf(entry, models);
74
+ }
75
+ if (expanded.has(entry.kind)) {
76
+ return [{ kind: 'all', provider: entry, count: models.length, open: true }, ...rowsOf(entry, models)];
77
+ }
78
+ const shortlist = models.filter((model) => model.isDefault || (current?.provider === entry.kind && current.model === model.slug));
79
+ return [...rowsOf(entry, shortlist), { kind: 'all', provider: entry, count: models.length, open: false }];
80
+ });
21
81
  if (legacy.length === 0) {
22
82
  return live;
23
83
  }
@@ -62,6 +62,7 @@ export function ModelPicker({
62
62
  const { t } = useTranslation('agent-chat');
63
63
  const [query, setQuery] = useState('');
64
64
  const [legacyOpen, setLegacyOpen] = useState(false);
65
+ const [expanded, setExpanded] = useState<ReadonlySet<AgentKind>>(new Set());
65
66
  const [index, setIndex] = useState(0);
66
67
  const inputRef = useRef<HTMLInputElement>(null);
67
68
  const listRef = useRef<HTMLDivElement>(null);
@@ -72,7 +73,10 @@ export function ModelPicker({
72
73
  const grouped = providers.length > 1;
73
74
  const trimmed = query.trim().toLowerCase();
74
75
 
75
- const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
76
+ const entries = useMemo(
77
+ () => pickerEntriesOf(providers, trimmed, legacyOpen, expanded, { provider, model: selection.model }),
78
+ [providers, trimmed, legacyOpen, expanded, provider, selection.model]
79
+ );
76
80
  const scope = useContext(ChatScopeContext);
77
81
  const machine = host.useMachineLabel(scope?.id ?? '');
78
82
  const reported = useReportedModels(provider);
@@ -85,6 +89,7 @@ export function ModelPicker({
85
89
  if (!next) {
86
90
  setQuery('');
87
91
  setLegacyOpen(false);
92
+ setExpanded(new Set());
88
93
  }
89
94
  setIndex(0);
90
95
  onOpenChange(next);
@@ -104,6 +109,18 @@ export function ModelPicker({
104
109
  inputRef.current?.focus();
105
110
  return;
106
111
  }
112
+ if (entry.kind === 'all') {
113
+ const kind = entry.provider.kind;
114
+ setExpanded((now) => {
115
+ const next = new Set(now);
116
+ if (!next.delete(kind)) {
117
+ next.add(kind);
118
+ }
119
+ return next;
120
+ });
121
+ inputRef.current?.focus();
122
+ return;
123
+ }
107
124
  if (requirementOf(entry) !== null) {
108
125
  return;
109
126
  }
@@ -165,6 +182,30 @@ export function ModelPicker({
165
182
  </div>
166
183
  )}
167
184
  {entries.map((entry, i) => {
185
+ if (entry.kind === 'all') {
186
+ const before = entries[i - 1];
187
+ const header = grouped && (before === undefined || before.kind === 'legacy' || before.provider.kind !== entry.provider.kind);
188
+ return (
189
+ <div key={`all-${entry.provider.kind}`}>
190
+ {header && (
191
+ <Menu.Label className="flex items-center gap-1.5">
192
+ <AgentIcon kind={entry.provider.kind} size={12} />
193
+ {entry.provider.name}
194
+ </Menu.Label>
195
+ )}
196
+ <button
197
+ data-active={i === index}
198
+ aria-expanded={entry.open}
199
+ className="cursor-row flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted"
200
+ onMouseEnter={() => setIndex(i)}
201
+ onClick={() => choose(entry)}
202
+ >
203
+ <Icon icon={entry.open ? ChevronDown : ChevronRight} size={12} />
204
+ {t('pickers.model.allGroup', { count: entry.count })}
205
+ </button>
206
+ </div>
207
+ );
208
+ }
168
209
  if (entry.kind === 'legacy') {
169
210
  return (
170
211
  <button
@@ -180,7 +221,17 @@ export function ModelPicker({
180
221
  );
181
222
  }
182
223
  const previous = entries[i - 1];
183
- const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
224
+ // A provider's rows start where the one before it ended; its row that opens the rest belongs to it.
225
+ const first = grouped && (previous === undefined || previous.kind === 'legacy' || previous.provider.kind !== entry.provider.kind);
226
+ // The makers read as groups only where the whole list is shown, so a short list is not cut by labels.
227
+ const vendorOpen = trimmed !== '' || expanded.has(entry.provider.kind);
228
+ const vendorFirst =
229
+ vendorOpen &&
230
+ entry.model.vendor !== undefined &&
231
+ (previous === undefined ||
232
+ previous.kind !== 'model' ||
233
+ previous.provider.kind !== entry.provider.kind ||
234
+ previous.model.vendor !== entry.model.vendor);
184
235
  const requirement = requirementOf(entry);
185
236
  const next = entries[i + 1];
186
237
  const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
@@ -193,6 +244,7 @@ export function ModelPicker({
193
244
  {entry.provider.name}
194
245
  </Menu.Label>
195
246
  )}
247
+ {vendorFirst && <Menu.Label>{entry.model.vendor}</Menu.Label>}
196
248
  <button
197
249
  role="option"
198
250
  aria-selected={chosen}
@@ -7,12 +7,13 @@ export const FOLLOW_THRESHOLD_PX = 40;
7
7
 
8
8
  /*
9
9
  * The gaps a row wears for where it sits: a question is followed by its answer gap, and a seam where
10
- * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A question
10
+ * the list rhythm of tool lines meets the block rhythm of prose gets the block gap. A visual has no
11
+ * margin of its own like prose does, so the row under one takes the block gap too. A question
11
12
  * already carries the gap before it, and the row after one is the answer, so neither takes a seam.
12
13
  */
13
14
  export function rowRhythm(row: TimelineRow, previous: TimelineRow | null): string {
14
15
  const question = row.kind === 'user';
15
- const seam = !question && previous !== null && previous.kind !== 'user' && isBlock(row) !== isBlock(previous);
16
+ const seam = !question && previous !== null && previous.kind !== 'user' && (isBlock(row) !== isBlock(previous) || previous.kind === 'visual');
16
17
  return clsx(question && 'pb-(--chat-answer-gap)', seam && 'pt-(--chat-block-gap)');
17
18
  }
18
19
 
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  isConnectionError
38
38
  } from './transport';
39
39
  export { portTransport, type PortTransport } from './port-transport';
40
+ export { configureAgentMarks, type AgentMark } from './agents/agent-marks';
40
41
  export { configureChatStorage, type ChatStorageRecord, type ChatStorageAdapter, type ChatStorageOptions } from './storage';
41
42
  export { setLazyPrefetch, onLazyOpenError } from './lazy';
42
43
  export { type MountedEntry, MountedRegistry } from './mounted-registry';
@@ -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',
@@ -65,7 +65,9 @@ export default {
65
65
  signedOut: 'Not logged in',
66
66
  signedOutDetail: "Opens a terminal that runs the CLI's login with this folder.",
67
67
  logIn: 'Log in',
68
- logInAgain: 'Log in again'
68
+ logInAgain: 'Log in again',
69
+ inChat: 'Logs in from a chat',
70
+ inChatDetail: 'The first chat on this account asks you to log in, and the CLI keeps that login in this folder.'
69
71
  },
70
72
  folder: {
71
73
  label: 'Folder',
@@ -106,6 +108,7 @@ export default {
106
108
  add: {
107
109
  title: 'Add a {{provider}} account',
108
110
  description: 'Ruimte makes a folder for it on the machine, then opens a terminal where you log in.',
111
+ chatDescription: 'A folder is made for it on the machine. The first chat on it asks you to log in.',
109
112
  name: 'Name',
110
113
  namePlaceholder: 'Work',
111
114
  color: 'Color',
@@ -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',
@@ -65,7 +65,9 @@ export default {
65
65
  signedOut: 'Niet ingelogd',
66
66
  signedOutDetail: 'Opent een terminal die de login van de CLI met deze map draait.',
67
67
  logIn: 'Inloggen',
68
- logInAgain: 'Opnieuw inloggen'
68
+ logInAgain: 'Opnieuw inloggen',
69
+ inChat: 'Logt in vanuit een chat',
70
+ inChatDetail: 'De eerste chat op dit account vraagt je in te loggen, en de CLI bewaart die login in deze map.'
69
71
  },
70
72
  folder: {
71
73
  label: 'Map',
@@ -106,6 +108,7 @@ export default {
106
108
  add: {
107
109
  title: 'Een {{provider}}-account toevoegen',
108
110
  description: 'Ruimte maakt er een map voor op de machine en opent dan een terminal waarin je inlogt.',
111
+ chatDescription: 'Er komt een map voor op de machine. De eerste chat erop vraagt je in te loggen.',
109
112
  name: 'Naam',
110
113
  namePlaceholder: 'Werk',
111
114
  color: 'Kleur',