@adecore/agents-react 0.30.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/AgentIcon.js +4 -3
- package/dist/agents/accounts.d.ts +2 -0
- package/dist/agents/accounts.js +6 -0
- package/dist/agents/agent-marks.d.ts +8 -0
- package/dist/agents/agent-marks.js +12 -0
- package/dist/agents/provider-paths.js +3 -2
- package/dist/host.d.ts +4 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/locales/en/agent-providers.d.ts +40 -0
- package/dist/locales/en/agent-providers.js +42 -2
- package/dist/locales/nl/agent-providers.d.ts +40 -0
- package/dist/locales/nl/agent-providers.js +42 -2
- package/dist/providers/AccountDetail.d.ts +4 -3
- package/dist/providers/AccountDetail.js +6 -5
- package/dist/providers/AddAccountForm.d.ts +3 -2
- package/dist/providers/AddAccountForm.js +2 -2
- package/dist/providers/CliDetail.d.ts +3 -3
- package/dist/providers/CliDetail.js +3 -3
- package/dist/providers/InstallPage.d.ts +9 -0
- package/dist/providers/InstallPage.js +74 -0
- package/dist/providers/ProvidersPane.d.ts +3 -1
- package/dist/providers/ProvidersPane.js +25 -14
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/install-steps.d.ts +14 -0
- package/dist/providers/install-steps.js +19 -0
- package/dist/providers/parts.js +2 -1
- package/dist/theme.css +3 -0
- package/dist/usage/format.js +6 -2
- package/dist/usage/limit-groups.js +2 -1
- package/package.json +8 -8
- package/src/agents/AgentIcon.tsx +12 -3
- package/src/agents/accounts.ts +13 -0
- package/src/agents/agent-marks.ts +26 -0
- package/src/agents/provider-paths.ts +3 -2
- package/src/host.ts +11 -1
- package/src/index.ts +1 -0
- package/src/locales/en/agent-providers.ts +42 -2
- package/src/locales/nl/agent-providers.ts +42 -2
- package/src/providers/AccountDetail.tsx +34 -26
- package/src/providers/AddAccountForm.tsx +8 -3
- package/src/providers/CliDetail.tsx +5 -5
- package/src/providers/InstallPage.tsx +221 -0
- package/src/providers/ProvidersPane.tsx +36 -19
- package/src/providers/index.ts +1 -0
- package/src/providers/install-steps.ts +35 -0
- package/src/providers/parts.tsx +2 -1
- package/src/theme.css +3 -0
- package/src/usage/format.ts +6 -2
- package/src/usage/limit-groups.ts +2 -1
|
@@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
|
|
3
3
|
import { LogIn } from 'lucide-react';
|
|
4
4
|
import type { ProviderInfo } from '@adecore/agent-contracts';
|
|
5
5
|
import { AccountDot } from '../agents/AccountDot';
|
|
6
|
-
import { accountName, FOLDER_VARIABLES, type AccountEntry } from '../agents/accounts';
|
|
6
|
+
import { accountName, FOLDER_VARIABLES, type AccountEntry, type AccountLogin } from '../agents/accounts';
|
|
7
7
|
import { ConfirmDialog, DetailHeader, SettingsRow, SettingsSection } from '@adecore/ui/settings';
|
|
8
8
|
import { Button, copyText, Icon, Input, Switch, Tooltip } from '@adecore/ui';
|
|
9
9
|
import { useChatHost } from '../host';
|
|
@@ -19,8 +19,10 @@ const TROUBLE = new Set(['folder-missing', 'unavailable', 'failed']);
|
|
|
19
19
|
interface AccountDetailProps {
|
|
20
20
|
provider: ProviderInfo;
|
|
21
21
|
entry: AccountEntry;
|
|
22
|
-
/*
|
|
23
|
-
|
|
22
|
+
/* Null for a CLI without a config folder: it has only its own login, and nothing to sign in here. */
|
|
23
|
+
login: AccountLogin;
|
|
24
|
+
/* Whether the host can open a terminal for a login at all. */
|
|
25
|
+
canOpenLogin: boolean;
|
|
24
26
|
/* Why a login cannot open a terminal right now, or null when it can. */
|
|
25
27
|
loginBlocked: string | null;
|
|
26
28
|
secretsAvailable: boolean;
|
|
@@ -56,7 +58,7 @@ function NameField({ value, label, onSave }: { value: string; label: string; onS
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/* One account: how it looks in a picker, who is signed in, where its folder is, its variables. */
|
|
59
|
-
export function AccountDetail({ provider, entry,
|
|
61
|
+
export function AccountDetail({ provider, entry, login, canOpenLogin, loginBlocked, secretsAvailable, onRemoved }: AccountDetailProps) {
|
|
60
62
|
const { t } = useTranslation('agent-providers');
|
|
61
63
|
const scope = useChatScope();
|
|
62
64
|
const host = useChatHost();
|
|
@@ -72,9 +74,11 @@ export function AccountDetail({ provider, entry, canLogIn, loginBlocked, secrets
|
|
|
72
74
|
host.notify({ kind: 'success', title: t('account.folderCopied'), description: folder });
|
|
73
75
|
};
|
|
74
76
|
|
|
77
|
+
const trouble = status !== null && TROUBLE.has(status.state) && status.message ? status.message : null;
|
|
78
|
+
|
|
75
79
|
const loginDetail = (): string => {
|
|
76
|
-
if (
|
|
77
|
-
return
|
|
80
|
+
if (trouble !== null) {
|
|
81
|
+
return trouble;
|
|
78
82
|
}
|
|
79
83
|
if (!signedIn) {
|
|
80
84
|
return t('account.login.signedOutDetail');
|
|
@@ -149,27 +153,31 @@ export function AccountDetail({ provider, entry, canLogIn, loginBlocked, secrets
|
|
|
149
153
|
}
|
|
150
154
|
/>
|
|
151
155
|
</SettingsSection>
|
|
152
|
-
{
|
|
156
|
+
{(login === 'chat' || (login === 'terminal' && canOpenLogin)) && (
|
|
153
157
|
<SettingsSection title={t('account.login.title')}>
|
|
154
|
-
|
|
155
|
-
label={
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
158
|
+
{login === 'chat' ? (
|
|
159
|
+
<SettingsRow label={t('account.login.inChat')} description={trouble ?? t('account.login.inChatDetail')} />
|
|
160
|
+
) : (
|
|
161
|
+
<SettingsRow
|
|
162
|
+
label={
|
|
163
|
+
signedIn
|
|
164
|
+
? status?.email
|
|
165
|
+
? t('account.login.signedInAs', { email: status.email })
|
|
166
|
+
: t('account.login.signedIn')
|
|
167
|
+
: t('account.login.signedOut')
|
|
168
|
+
}
|
|
169
|
+
description={loginBlocked === null ? loginDetail() : `${loginDetail()} ${loginBlocked}`}
|
|
170
|
+
control={
|
|
171
|
+
loginBlocked === null ? (
|
|
172
|
+
loginButton
|
|
173
|
+
) : (
|
|
174
|
+
<Tooltip label={loginBlocked}>
|
|
175
|
+
<span className="inline-flex">{loginButton}</span>
|
|
176
|
+
</Tooltip>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
173
181
|
<SettingsRow label={t('account.folder.label')}>
|
|
174
182
|
<Input mono className="h-8.5" value={folder} readOnly aria-label={t('account.folder.label')} />
|
|
175
183
|
<p className="-mt-1 text-xs text-text-muted">
|
|
@@ -3,7 +3,7 @@ import clsx from 'clsx';
|
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import { ChevronRight } from 'lucide-react';
|
|
5
5
|
import type { ProviderInfo } from '@adecore/agent-contracts';
|
|
6
|
-
import { freeAccountColor, mintAccountId, type AccountEntry } from '../agents/accounts';
|
|
6
|
+
import { freeAccountColor, mintAccountId, type AccountEntry, type AccountLogin } from '../agents/accounts';
|
|
7
7
|
import { useChatHost } from '../host';
|
|
8
8
|
import { useChatScope } from '../scope';
|
|
9
9
|
import { createAccount, linkAccount } from './account-actions';
|
|
@@ -16,6 +16,7 @@ import { providerAccountsOf } from '../state/provider-accounts';
|
|
|
16
16
|
interface AddAccountFormProps {
|
|
17
17
|
provider: ProviderInfo;
|
|
18
18
|
entries: AccountEntry[];
|
|
19
|
+
login: AccountLogin;
|
|
19
20
|
onCancel(): void;
|
|
20
21
|
/* The account exists; `made` says the machine made its folder, so nobody signed in there yet. */
|
|
21
22
|
onAdded(id: string, name: string, made: boolean): void;
|
|
@@ -25,7 +26,7 @@ interface AddAccountFormProps {
|
|
|
25
26
|
* A name and a color, asked before anything exists, so the account is made once with both. By default
|
|
26
27
|
* the machine makes the folder; under Advanced a person points at a folder of their own instead.
|
|
27
28
|
*/
|
|
28
|
-
export function AddAccountForm({ provider, entries, onCancel, onAdded }: AddAccountFormProps) {
|
|
29
|
+
export function AddAccountForm({ provider, entries, login, onCancel, onAdded }: AddAccountFormProps) {
|
|
29
30
|
const { t } = useTranslation('agent-providers');
|
|
30
31
|
const scope = useChatScope();
|
|
31
32
|
const { accents } = useChatHost();
|
|
@@ -62,7 +63,11 @@ export function AddAccountForm({ provider, entries, onCancel, onAdded }: AddAcco
|
|
|
62
63
|
|
|
63
64
|
return (
|
|
64
65
|
<form className="contents" onSubmit={(event) => void submit(event)}>
|
|
65
|
-
<DetailHeader
|
|
66
|
+
<DetailHeader
|
|
67
|
+
mark={<CliTile kind={provider.kind} />}
|
|
68
|
+
title={t('add.title', { provider: provider.name })}
|
|
69
|
+
subtitle={login === 'chat' ? t('add.chatDescription') : t('add.description')}
|
|
70
|
+
/>
|
|
66
71
|
<SettingsSection>
|
|
67
72
|
<SettingsRow
|
|
68
73
|
label={t('add.name')}
|
|
@@ -4,7 +4,7 @@ import type { AgentKind, ModelInfo, ProviderInfo } from '@adecore/agent-contract
|
|
|
4
4
|
import { AccountDot } from '../agents/AccountDot';
|
|
5
5
|
import { ModelOptionControl } from '../agents/ModelOptionControl';
|
|
6
6
|
import { carryOptions } from '../agents/model-options';
|
|
7
|
-
import { ACCOUNT_TONE_CLASSES, accountName, accountStatusLine, type AccountEntry } from '../agents/accounts';
|
|
7
|
+
import { ACCOUNT_TONE_CLASSES, accountName, accountStatusLine, type AccountEntry, type AccountLogin } from '../agents/accounts';
|
|
8
8
|
import { accountFor, forgetChatSelection, rememberChatAccount, rememberChatSelection, selectionFor, useChatPreferences } from '../chat/preferences';
|
|
9
9
|
import { useChatScope } from '../scope';
|
|
10
10
|
import { providerAbilities } from './provider-abilities';
|
|
@@ -40,13 +40,13 @@ interface CliDetailProps {
|
|
|
40
40
|
/* Null for a machine that keeps no accounts. */
|
|
41
41
|
entries: AccountEntry[] | null;
|
|
42
42
|
defaults: boolean;
|
|
43
|
-
|
|
43
|
+
login: AccountLogin;
|
|
44
44
|
onAddAccount(): void;
|
|
45
45
|
onPickAccount(id: string): void;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/* One CLI: what it is, what a new chat of it starts with unless `defaults` is off, and its accounts. */
|
|
49
|
-
export function CliDetail({ provider, entries, defaults,
|
|
49
|
+
export function CliDetail({ provider, entries, defaults, login, onAddAccount, onPickAccount }: CliDetailProps) {
|
|
50
50
|
const { t } = useTranslation('agent-providers');
|
|
51
51
|
const { id: scopeId } = useChatScope();
|
|
52
52
|
const preferences = useChatPreferences();
|
|
@@ -56,7 +56,7 @@ export function CliDetail({ provider, entries, defaults, canAddAccount, onAddAcc
|
|
|
56
56
|
const picked = accountFor(preferences, scopeId, provider.kind, machineAccounts) ?? provider.kind;
|
|
57
57
|
// An account that is off is out of every picker, unless it is the one picked already.
|
|
58
58
|
const choosable = (entries ?? []).filter((entry) => entry.account.enabled !== false || entry.id === picked);
|
|
59
|
-
const
|
|
59
|
+
const canAddAccount = login !== null;
|
|
60
60
|
|
|
61
61
|
return (
|
|
62
62
|
<>
|
|
@@ -135,7 +135,7 @@ export function CliDetail({ provider, entries, defaults, canAddAccount, onAddAcc
|
|
|
135
135
|
) : (
|
|
136
136
|
<SettingsSection title={t('cli.accounts')} footer={canAddAccount ? undefined : t('cli.onlyDefault', { provider: provider.name })}>
|
|
137
137
|
{entries.map((entry) => {
|
|
138
|
-
const status = accountStatusLine(entry.status,
|
|
138
|
+
const status = accountStatusLine(entry.status, login === 'terminal');
|
|
139
139
|
return (
|
|
140
140
|
<button
|
|
141
141
|
key={entry.id}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { Trans, useTranslation } from 'react-i18next';
|
|
4
|
+
import { Check, Copy, ExternalLink, SquareTerminal } from 'lucide-react';
|
|
5
|
+
import type { ProviderInfo, ProviderPlatform, ProviderSignIn } from '@adecore/agent-contracts';
|
|
6
|
+
import { Button, copyText, Icon, Pill, Segmented } from '@adecore/ui';
|
|
7
|
+
import { useChatHost } from '../host';
|
|
8
|
+
import { useChatScope } from '../scope';
|
|
9
|
+
import { installPlatforms, installStepStates, pagePlatform, type InstallStepState, type ProviderInstallStep } from './install-steps';
|
|
10
|
+
import { CliMark } from './parts';
|
|
11
|
+
|
|
12
|
+
const noPlatform = (): ProviderPlatform | null => null;
|
|
13
|
+
|
|
14
|
+
function CommandBlock({ command }: { command: string }) {
|
|
15
|
+
const { t } = useTranslation('agent-providers');
|
|
16
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-border bg-surface-sunken py-1 pr-1 pl-3">
|
|
19
|
+
<code className="min-w-0 grow font-mono text-code break-all text-text select-text">{command}</code>
|
|
20
|
+
<Button
|
|
21
|
+
size="xs"
|
|
22
|
+
onClick={() => {
|
|
23
|
+
copyText(command);
|
|
24
|
+
setCopied(command);
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<Icon icon={copied === command ? Check : Copy} size={12} />
|
|
28
|
+
{copied === command ? t('install.copied') : t('install.copy')}
|
|
29
|
+
</Button>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Step({ index, state, title, children }: { index: number; state: InstallStepState; title: string; children?: ReactNode }) {
|
|
35
|
+
return (
|
|
36
|
+
<li
|
|
37
|
+
aria-current={state === 'current' ? 'step' : undefined}
|
|
38
|
+
data-state={state}
|
|
39
|
+
className={clsx('flex min-w-0 gap-3 rounded-xl border p-4', state === 'current' ? 'border-accent' : 'border-border')}
|
|
40
|
+
>
|
|
41
|
+
<span
|
|
42
|
+
className={clsx(
|
|
43
|
+
'grid size-6 shrink-0 place-items-center rounded-full text-xs font-medium',
|
|
44
|
+
state === 'done' && 'bg-positive text-positive-text',
|
|
45
|
+
state === 'current' && 'border border-accent text-accent',
|
|
46
|
+
state === 'later' && 'bg-text/7 text-text-faint'
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{state === 'done' ? <Icon icon={Check} size={14} /> : index + 1}
|
|
50
|
+
</span>
|
|
51
|
+
<div className="flex min-w-0 grow flex-col gap-3">
|
|
52
|
+
<h4 className={clsx('text-sm leading-6 font-medium', state === 'later' ? 'text-text-muted' : 'text-text')}>{title}</h4>
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
</li>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function SignInWay({ way, canRun, onRun }: { way: ProviderSignIn; canRun: boolean; onRun(command: string): void }) {
|
|
60
|
+
const { t } = useTranslation('agent-providers');
|
|
61
|
+
const { providerKeyNote } = useChatHost();
|
|
62
|
+
switch (way.kind) {
|
|
63
|
+
case 'command':
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
66
|
+
<p className="text-xs text-text-muted">{t('install.signIn.command')}</p>
|
|
67
|
+
<CommandBlock command={way.command} />
|
|
68
|
+
{canRun && (
|
|
69
|
+
<Button variant="secondary" size="sm" className="self-start" onClick={() => onRun(way.command)}>
|
|
70
|
+
<Icon icon={SquareTerminal} size={14} />
|
|
71
|
+
{t('install.run')}
|
|
72
|
+
</Button>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
case 'env': {
|
|
77
|
+
const note = providerKeyNote?.(way.variable) ?? null;
|
|
78
|
+
return (
|
|
79
|
+
<p className="text-xs text-text-muted">
|
|
80
|
+
<Trans
|
|
81
|
+
t={t}
|
|
82
|
+
i18nKey="install.signIn.env"
|
|
83
|
+
values={{ variable: way.variable }}
|
|
84
|
+
components={{ code: <code className="font-mono text-code text-text" /> }}
|
|
85
|
+
/>
|
|
86
|
+
{note !== null && ` ${note}`}
|
|
87
|
+
</p>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
case 'chat':
|
|
91
|
+
return <p className="text-xs text-text-muted">{t('install.signIn.chat')}</p>;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface InstallPageProps {
|
|
96
|
+
provider: ProviderInfo;
|
|
97
|
+
signedIn: boolean;
|
|
98
|
+
/* The host's own step, after the install of the CLI. */
|
|
99
|
+
extra: ProviderInstallStep | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* How to get a CLI that is not on the machine yet, step by step, until it is there and signed in. */
|
|
103
|
+
export function InstallPage({ provider, signedIn, extra }: InstallPageProps) {
|
|
104
|
+
const { t } = useTranslation('agent-providers');
|
|
105
|
+
const { id: scopeId } = useChatScope();
|
|
106
|
+
const host = useChatHost();
|
|
107
|
+
const useMachinePlatform = host.useMachinePlatform ?? noPlatform;
|
|
108
|
+
const machine = useMachinePlatform(scopeId) ?? pagePlatform();
|
|
109
|
+
const { offered, initial } = installPlatforms(provider, machine);
|
|
110
|
+
// Only a person's pick is kept, so a machine whose system arrives late still opens on its own.
|
|
111
|
+
const [chosen, setPlatform] = useState<ProviderPlatform | null>(null);
|
|
112
|
+
const platform = chosen ?? initial;
|
|
113
|
+
const install = provider.install;
|
|
114
|
+
const run = host.runProviderCommand;
|
|
115
|
+
const onRun = (command: string): void => run?.(scopeId, provider.kind, command);
|
|
116
|
+
const command = platform === null ? undefined : install?.commands[platform];
|
|
117
|
+
|
|
118
|
+
const steps: { title: string; done: boolean; body: ReactNode }[] = [];
|
|
119
|
+
steps.push({
|
|
120
|
+
title: t('install.install.title'),
|
|
121
|
+
done: provider.installed,
|
|
122
|
+
body: provider.installed ? (
|
|
123
|
+
<p className="text-xs text-text-muted">
|
|
124
|
+
{provider.version ? t('install.install.done', { version: provider.version }) : t('install.install.doneNoVersion')}
|
|
125
|
+
</p>
|
|
126
|
+
) : (
|
|
127
|
+
<>
|
|
128
|
+
{offered.length > 1 && platform !== null && (
|
|
129
|
+
<Segmented<ProviderPlatform>
|
|
130
|
+
value={platform}
|
|
131
|
+
label={t('install.platform')}
|
|
132
|
+
options={offered.map((id) => ({ id, label: t(`install.platforms.${id}`) }))}
|
|
133
|
+
onValueChange={setPlatform}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
{command ? <CommandBlock command={command} /> : <p className="text-xs text-text-muted">{t('install.install.none')}</p>}
|
|
137
|
+
{((command && run) || install?.guideUrl) && (
|
|
138
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
139
|
+
{command && run && (
|
|
140
|
+
<Button variant="primary" onClick={() => onRun(command)}>
|
|
141
|
+
<Icon icon={SquareTerminal} size={14} />
|
|
142
|
+
{t('install.run')}
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
{install?.guideUrl && (
|
|
146
|
+
<Button href={install.guideUrl}>
|
|
147
|
+
{t('install.guide')}
|
|
148
|
+
<Icon icon={ExternalLink} size={14} />
|
|
149
|
+
</Button>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
155
|
+
});
|
|
156
|
+
if (extra !== null) {
|
|
157
|
+
steps.push(extra);
|
|
158
|
+
}
|
|
159
|
+
const ways = install?.signIn ?? [];
|
|
160
|
+
steps.push({
|
|
161
|
+
title: t('install.signIn.title'),
|
|
162
|
+
done: provider.installed && signedIn,
|
|
163
|
+
body:
|
|
164
|
+
ways.length === 0 ? (
|
|
165
|
+
<p className="text-xs text-text-muted">{t('install.signIn.unknown')}</p>
|
|
166
|
+
) : (
|
|
167
|
+
ways.map((way, index) => (
|
|
168
|
+
<div key={`${way.kind}-${index}`} className="flex min-w-0 flex-col gap-2">
|
|
169
|
+
{index > 0 && <span className="text-xs font-medium text-text-faint">{t('install.signIn.or')}</span>}
|
|
170
|
+
<SignInWay way={way} canRun={run !== undefined && provider.installed} onRun={onRun} />
|
|
171
|
+
</div>
|
|
172
|
+
))
|
|
173
|
+
)
|
|
174
|
+
});
|
|
175
|
+
steps.push({
|
|
176
|
+
title: t('install.start.title'),
|
|
177
|
+
done: false,
|
|
178
|
+
body: <p className="text-xs text-text-muted">{t('install.start.description', { provider: provider.name })}</p>
|
|
179
|
+
});
|
|
180
|
+
const states = installStepStates(steps.map((step) => step.done));
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<>
|
|
184
|
+
<header className="flex min-w-0 items-start gap-3">
|
|
185
|
+
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-surface-sunken">
|
|
186
|
+
<CliMark kind={provider.kind} size={20} />
|
|
187
|
+
</span>
|
|
188
|
+
<div className="flex min-w-0 grow flex-col">
|
|
189
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
190
|
+
<h3 className="truncate text-lg font-semibold text-text">{provider.name}</h3>
|
|
191
|
+
{provider.installed ? (
|
|
192
|
+
provider.version && (
|
|
193
|
+
<Pill tone="idle" mono>
|
|
194
|
+
{provider.version}
|
|
195
|
+
</Pill>
|
|
196
|
+
)
|
|
197
|
+
) : (
|
|
198
|
+
<Pill>{t('install.notInstalled')}</Pill>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
{install && <p className="text-xs text-text-muted">{install.description}</p>}
|
|
202
|
+
</div>
|
|
203
|
+
</header>
|
|
204
|
+
{install ? (
|
|
205
|
+
<ol aria-label={t('install.steps', { provider: provider.name })} className="flex min-w-0 flex-col gap-3">
|
|
206
|
+
{steps.map((step, index) => (
|
|
207
|
+
<Step key={index} index={index} state={states[index]!} title={step.title}>
|
|
208
|
+
{step.body}
|
|
209
|
+
</Step>
|
|
210
|
+
))}
|
|
211
|
+
</ol>
|
|
212
|
+
) : (
|
|
213
|
+
<p className="text-xs text-text-muted">{t('install.fallback', { provider: provider.name })}</p>
|
|
214
|
+
)}
|
|
215
|
+
<p className="flex min-w-0 items-center gap-2 text-xs text-text-muted">
|
|
216
|
+
<span aria-hidden className={clsx('size-2 shrink-0 rounded-full', provider.installed ? 'bg-status-idle' : 'bg-text-faint')} />
|
|
217
|
+
{provider.installed ? t('install.footer.found') : t('install.footer.missing')}
|
|
218
|
+
</p>
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { useMemo, useState, type ReactNode } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { Plus
|
|
4
|
+
import { Plus } from 'lucide-react';
|
|
5
5
|
import type { AgentKind, ProviderInfo } from '@adecore/agent-contracts';
|
|
6
6
|
import { AccountDot } from '../agents/AccountDot';
|
|
7
|
-
import { ACCOUNT_TONE_CLASSES, accountName, accountsOfKind, accountStatusLine, type AccountEntry } from '../agents/accounts';
|
|
8
|
-
import { ErrorBoundary,
|
|
7
|
+
import { ACCOUNT_TONE_CLASSES, accountLoginOf, accountName, accountsOfKind, accountStatusLine, type AccountEntry } from '../agents/accounts';
|
|
8
|
+
import { ErrorBoundary, IconButton, SectionLabel, Skeleton } from '@adecore/ui';
|
|
9
9
|
import { MasterDetail } from '@adecore/ui/settings';
|
|
10
10
|
import { useChatHost } from '../host';
|
|
11
11
|
import { useChatScope } from '../scope';
|
|
12
12
|
import { AccountDetail } from './AccountDetail';
|
|
13
13
|
import { AddAccountForm } from './AddAccountForm';
|
|
14
14
|
import { CliDetail } from './CliDetail';
|
|
15
|
+
import { InstallPage } from './InstallPage';
|
|
16
|
+
import type { ProviderInstallStep } from './install-steps';
|
|
15
17
|
import { CliMark } from './parts';
|
|
16
18
|
import { useProviderAccounts } from '../state/provider-accounts';
|
|
17
19
|
import { useProviders } from '../state/providers';
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
// `install` is a CLI picked while it was missing: its install page stays until it is installed and signed in.
|
|
22
|
+
type Picked = { kind: 'cli'; cli: AgentKind } | { kind: 'install'; cli: AgentKind } | { kind: 'account'; id: string } | { kind: 'add'; cli: AgentKind };
|
|
20
23
|
|
|
21
24
|
/* A row of the list with a control of its own beside the part that picks it, so the two never nest. */
|
|
22
25
|
function ListRow({
|
|
@@ -54,10 +57,12 @@ interface ProvidersPaneProps {
|
|
|
54
57
|
detailOf?(provider: ProviderInfo): ReactNode | null;
|
|
55
58
|
/* False leaves "Defaults for new chats" out of a CLI's detail, for an app that decides those elsewhere. */
|
|
56
59
|
defaults?: boolean;
|
|
60
|
+
/* A step of the app's own on the install page of a CLI, after its install, such as a server its chat needs; null adds none. */
|
|
61
|
+
installStepOf?(provider: ProviderInfo): ProviderInstallStep | null;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/* The agent CLIs of the host in scope, each with its accounts, beside the detail of what is picked. */
|
|
60
|
-
export function ProvidersPane({ target = null, detailOf, defaults = true }: ProvidersPaneProps) {
|
|
65
|
+
export function ProvidersPane({ target = null, detailOf, defaults = true, installStepOf }: ProvidersPaneProps) {
|
|
61
66
|
const { t } = useTranslation('agent-providers');
|
|
62
67
|
const { id: scopeId } = useChatScope();
|
|
63
68
|
const providers = useProviders((s) => s.providers);
|
|
@@ -72,7 +77,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
72
77
|
const installed = useMemo(() => providers.filter((provider) => provider.installed || !provider.capabilities.terminal), [providers]);
|
|
73
78
|
const missing = useMemo(() => providers.filter((provider) => !provider.installed && provider.capabilities.terminal), [providers]);
|
|
74
79
|
const entriesOf = (kind: AgentKind): AccountEntry[] => accountsOfKind(accounts, kind);
|
|
75
|
-
const
|
|
80
|
+
const loginOf = (kind: AgentKind) => accountLoginOf(accounts, kind);
|
|
76
81
|
|
|
77
82
|
// What was picked may be gone since (an account removed elsewhere); the first CLI stands in for it.
|
|
78
83
|
const pickedEntry = picked?.kind === 'account' ? (Object.hasOwn(accounts?.accounts ?? {}, picked.id) ? picked.id : null) : null;
|
|
@@ -80,7 +85,8 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
80
85
|
picked !== null && (picked.kind !== 'account' || pickedEntry !== null) ? picked : installed[0] ? { kind: 'cli', cli: installed[0].kind } : null;
|
|
81
86
|
const currentAccount = current?.kind === 'account' ? accounts?.accounts[current.id] : undefined;
|
|
82
87
|
const currentCli = current === null ? null : current.kind === 'account' ? (currentAccount?.kind as AgentKind | undefined) : current.cli;
|
|
83
|
-
const provider = installed.find((entry) => entry.kind === currentCli) ?? null;
|
|
88
|
+
const provider = (current?.kind === 'install' ? providers : installed).find((entry) => entry.kind === currentCli) ?? null;
|
|
89
|
+
const picksCli = (kind: AgentKind): boolean => (current?.kind === 'cli' || current?.kind === 'install') && current.cli === kind;
|
|
84
90
|
|
|
85
91
|
// A search result lands on a row of a CLI's detail, so a picked account makes way for its CLI.
|
|
86
92
|
const [seenTarget, setSeenTarget] = useState(target);
|
|
@@ -95,7 +101,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
95
101
|
|
|
96
102
|
const added = (id: string, name: string, made: boolean): void => {
|
|
97
103
|
setPicked({ kind: 'account', id });
|
|
98
|
-
if (made && provider !== null && openLogin !== null && loginBlocked === null) {
|
|
104
|
+
if (made && provider !== null && loginOf(provider.kind) === 'terminal' && openLogin !== null && loginBlocked === null) {
|
|
99
105
|
void openLogin(scopeId, provider.kind, id, name);
|
|
100
106
|
}
|
|
101
107
|
};
|
|
@@ -105,11 +111,11 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
105
111
|
return (
|
|
106
112
|
<div key={cli.kind} className="flex min-w-0 shrink-0 flex-col gap-0.5 pt-1">
|
|
107
113
|
<ListRow
|
|
108
|
-
selected={
|
|
114
|
+
selected={picksCli(cli.kind)}
|
|
109
115
|
onSelect={() => setPicked({ kind: 'cli', cli: cli.kind })}
|
|
110
116
|
className="pr-1"
|
|
111
117
|
trailing={
|
|
112
|
-
|
|
118
|
+
loginOf(cli.kind) !== null && (
|
|
113
119
|
<IconButton
|
|
114
120
|
icon={Plus}
|
|
115
121
|
size="sm"
|
|
@@ -125,7 +131,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
125
131
|
</ListRow>
|
|
126
132
|
{entries.map((entry) => {
|
|
127
133
|
const name = accountName(entry, cli.name);
|
|
128
|
-
const status = accountStatusLine(entry.status,
|
|
134
|
+
const status = accountStatusLine(entry.status, loginOf(cli.kind) === 'terminal');
|
|
129
135
|
return (
|
|
130
136
|
<ListRow
|
|
131
137
|
key={entry.id}
|
|
@@ -164,13 +170,13 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
164
170
|
<div className="mt-1.5 flex min-w-0 shrink-0 flex-col border-t border-border pt-2.5 pb-2">
|
|
165
171
|
<SectionLabel className="px-3 pt-1 pb-1.5">{t('list.notInstalled')}</SectionLabel>
|
|
166
172
|
{missing.map((cli) => (
|
|
167
|
-
<
|
|
168
|
-
<
|
|
169
|
-
<span className="min-w-0 grow truncate">{cli.name}</span>
|
|
173
|
+
<ListRow key={cli.kind} selected={picksCli(cli.kind)} onSelect={() => setPicked({ kind: 'install', cli: cli.kind })}>
|
|
174
|
+
<CliMark kind={cli.kind} className="opacity-60" />
|
|
175
|
+
<span className="min-w-0 grow truncate text-xs font-medium text-text-muted">{cli.name}</span>
|
|
170
176
|
<span className="shrink-0 text-xs text-text-faint">{t('list.missing')}</span>
|
|
171
|
-
</
|
|
177
|
+
</ListRow>
|
|
172
178
|
))}
|
|
173
|
-
<p className="mt-1 px-3 text-xs text-text-faint">{t('list.notInstalledNote')}</p>
|
|
179
|
+
{missing.every((cli) => cli.install === undefined) && <p className="mt-1 px-3 text-xs text-text-faint">{t('list.notInstalledNote')}</p>}
|
|
174
180
|
</div>
|
|
175
181
|
)}
|
|
176
182
|
</>
|
|
@@ -180,7 +186,16 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
180
186
|
if (current === null || provider === null) {
|
|
181
187
|
return null;
|
|
182
188
|
}
|
|
183
|
-
|
|
189
|
+
if (current.kind === 'install') {
|
|
190
|
+
const extra = installStepOf?.(provider) ?? null;
|
|
191
|
+
const statuses = entriesOf(provider.kind).map((entry) => entry.status?.state);
|
|
192
|
+
// Without accounts to read, nothing says the CLI is signed out, so being there is all it takes.
|
|
193
|
+
const signedIn = statuses.length === 0 || statuses.includes('ready');
|
|
194
|
+
if (!provider.installed || !signedIn || (extra !== null && !extra.done)) {
|
|
195
|
+
return <InstallPage key={provider.kind} provider={provider} signedIn={signedIn} extra={extra} />;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const own = current.kind === 'cli' || current.kind === 'install' ? (detailOf?.(provider) ?? null) : null;
|
|
184
199
|
if (own !== null) {
|
|
185
200
|
return own;
|
|
186
201
|
}
|
|
@@ -190,6 +205,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
190
205
|
key={provider.kind}
|
|
191
206
|
provider={provider}
|
|
192
207
|
entries={entriesOf(provider.kind)}
|
|
208
|
+
login={loginOf(provider.kind)}
|
|
193
209
|
onCancel={() => setPicked({ kind: 'cli', cli: provider.kind })}
|
|
194
210
|
onAdded={added}
|
|
195
211
|
/>
|
|
@@ -203,7 +219,8 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
203
219
|
key={entry.id}
|
|
204
220
|
provider={provider}
|
|
205
221
|
entry={entry}
|
|
206
|
-
|
|
222
|
+
login={loginOf(provider.kind)}
|
|
223
|
+
canOpenLogin={openLogin !== null}
|
|
207
224
|
loginBlocked={loginBlocked}
|
|
208
225
|
secretsAvailable={accounts?.secretsAvailable === true}
|
|
209
226
|
onRemoved={() => setPicked({ kind: 'cli', cli: provider.kind })}
|
|
@@ -216,7 +233,7 @@ export function ProvidersPane({ target = null, detailOf, defaults = true }: Prov
|
|
|
216
233
|
provider={provider}
|
|
217
234
|
entries={accounts === null ? null : entriesOf(provider.kind)}
|
|
218
235
|
defaults={defaults}
|
|
219
|
-
|
|
236
|
+
login={loginOf(provider.kind)}
|
|
220
237
|
onAddAccount={() => setPicked({ kind: 'add', cli: provider.kind })}
|
|
221
238
|
onPickAccount={(id) => setPicked({ kind: 'account', id })}
|
|
222
239
|
/>
|
package/src/providers/index.ts
CHANGED
|
@@ -8,4 +8,5 @@ export { modelName, useModelName, shortModelName } from '../agents/model-name';
|
|
|
8
8
|
export { carryOptions } from '../agents/model-options';
|
|
9
9
|
export { type ModelRequirement } from '../agents/model-requirement';
|
|
10
10
|
export { ProvidersPane } from './ProvidersPane';
|
|
11
|
+
export type { ProviderInstallStep } from './install-steps';
|
|
11
12
|
export { CliMark, CliTile } from './parts';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ProviderInfo, ProviderPlatform } from '@adecore/agent-contracts';
|
|
3
|
+
import { isApplePlatform } from '@adecore/ui';
|
|
4
|
+
|
|
5
|
+
/* A step of the host's own on a provider's install page, such as a server its chat needs beside the CLI. */
|
|
6
|
+
export interface ProviderInstallStep {
|
|
7
|
+
title: string;
|
|
8
|
+
done: boolean;
|
|
9
|
+
/* Drawn under the title; the page numbers the step and outlines it while it is the current one. */
|
|
10
|
+
body: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type InstallStepState = 'done' | 'current' | 'later';
|
|
14
|
+
|
|
15
|
+
const PLATFORMS: readonly ProviderPlatform[] = ['darwin', 'linux', 'win32'];
|
|
16
|
+
|
|
17
|
+
/* The first step not done is the current one. */
|
|
18
|
+
export function installStepStates(done: readonly boolean[]): InstallStepState[] {
|
|
19
|
+
const first = done.indexOf(false);
|
|
20
|
+
return done.map((value, index) => (value ? 'done' : index === first ? 'current' : 'later'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* The system of the page, for a host that does not say which system its machine runs. */
|
|
24
|
+
export function pagePlatform(): ProviderPlatform {
|
|
25
|
+
if (isApplePlatform()) {
|
|
26
|
+
return 'darwin';
|
|
27
|
+
}
|
|
28
|
+
return typeof navigator !== 'undefined' && /windows/i.test(navigator.userAgent) ? 'win32' : 'linux';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* The systems a provider has a command for, and the one the page opens on: the machine's when it has one there. */
|
|
32
|
+
export function installPlatforms(provider: ProviderInfo, machine: ProviderPlatform): { offered: ProviderPlatform[]; initial: ProviderPlatform | null } {
|
|
33
|
+
const offered = PLATFORMS.filter((platform) => Boolean(provider.install?.commands[platform]));
|
|
34
|
+
return { offered, initial: offered.includes(machine) ? machine : (offered[0] ?? null) };
|
|
35
|
+
}
|
package/src/providers/parts.tsx
CHANGED
|
@@ -8,7 +8,8 @@ const CLI_COLORS: Partial<Record<AgentKind, string>> = {
|
|
|
8
8
|
claude: 'text-chart-claude',
|
|
9
9
|
codex: 'text-chart-codex',
|
|
10
10
|
gemini: 'text-chart-gemini',
|
|
11
|
-
copilot: 'text-chart-copilot'
|
|
11
|
+
copilot: 'text-chart-copilot',
|
|
12
|
+
nanogpt: 'text-chart-nanogpt'
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export function CliMark({ kind, size = 14, className }: { kind: AgentKind; size?: number; className?: string }) {
|
package/src/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);
|