[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
filterComposerModels,
resolveComposerEnterModelId,
resolvePinnedAndRecentModels,
type ComposerModelPrefEntry,
type ComposerModelPrefs,
type ComposerPickerModel,
} from '../../infrastructure/ai/composerPicker';
import type { AgentModelPreset, ProviderConfig } from '../../infrastructure/ai/types';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { useProviderModelCatalog } from './useProviderModelCatalog';
export const COMPOSER_PROVIDER_PICKER_WIDTH = 260;
export const COMPOSER_MODEL_PICKER_WIDTH = 260;
export interface ComposerModelPickerProps {
providers?: ProviderConfig[];
selectedProviderId?: string;
selectedModelId?: string;
modelPresets?: AgentModelPreset[];
prefs: ComposerModelPrefs;
onSelectProviderModel?: (providerId: string, modelId: string, contextWindow?: number) => void;
onSelectModel?: (modelId: string) => void;
onTogglePinned: (entry: ComposerModelPrefEntry) => void;
}
const rowClassName =
'flex h-8 w-full items-center gap-2 px-2.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer';
const SectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] tracking-wide text-muted-foreground/45">
{children}
</div>
);
const ModelRow: React.FC<{
model: ComposerPickerModel;
selected: boolean;
pinned: boolean;
onSelect: () => void;
onTogglePinned: () => void;
pinLabel: string;
unpinLabel: string;
}> = ({ model, selected, pinned, onSelect, onTogglePinned, pinLabel, unpinLabel }) => (
<div className="group/row relative">
<button
type="button"
role="option"
aria-selected={selected}
onClick={onSelect}
className={rowClassName}
>
{selected
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />}
<span className="min-w-0 flex-1 truncate text-foreground/88">{model.name}</span>
</button>
<button
type="button"
aria-label={pinned ? unpinLabel : pinLabel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onTogglePinned();
}}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 transition-opacity ${
pinned
? 'text-amber-400/90 opacity-100'
: 'text-muted-foreground/45 opacity-0 group-hover/row:opacity-100 hover:text-foreground/70'
}`}
>
<Star size={11} fill={pinned ? 'currentColor' : 'none'} />
</button>
</div>
);
export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
providers = [],
selectedProviderId,
selectedModelId,
modelPresets = [],
prefs,
onSelectProviderModel,
onSelectModel,
onTogglePinned,
}) => {
const { t } = useI18n();
const hasProviders = providers.length > 0;
const [previewProviderId, setPreviewProviderId] = useState(
selectedProviderId || providers[0]?.id || '',
);
const [query, setQuery] = useState('');
const [view, setView] = useState<'models' | 'providers'>('models');
useEffect(() => {
if (selectedProviderId) setPreviewProviderId(selectedProviderId);
}, [selectedProviderId]);
const previewProvider = hasProviders
? providers.find((provider) => provider.id === previewProviderId) ?? providers[0]
: undefined;
const catalog = useProviderModelCatalog(previewProvider, hasProviders);
const models = useMemo<ComposerPickerModel[]>(() => {
if (hasProviders) return catalog.models;
return modelPresets.map((preset) => ({
id: preset.id,
name: preset.name,
description: preset.description,
}));
}, [catalog.models, hasProviders, modelPresets]);
const filtered = useMemo(() => filterComposerModels(models, query), [models, query]);
const grouped = useMemo(
() => resolvePinnedAndRecentModels({
models: filtered,
prefs,
providerId: previewProvider?.id,
allowMissing: Boolean(previewProvider?.id) && !query.trim(),
}),
[filtered, prefs, previewProvider?.id, query],
);
const pinnedKeys = useMemo(
() => new Set(
prefs.pinned
.filter((entry) => !previewProvider || !entry.providerId || entry.providerId === previewProvider.id)
.map((entry) => entry.modelId),
),
[prefs.pinned, previewProvider],
);
const trimmedQuery = query.trim();
const showCustom = Boolean(
hasProviders
&& trimmedQuery
&& !models.some((model) => model.id.toLowerCase() === trimmedQuery.toLowerCase()),
);
const selectModel = (modelId: string) => {
const contextWindow = models.find((model) => model.id === modelId)?.contextWindow;
if (hasProviders && previewProvider) {
onSelectProviderModel?.(previewProvider.id, modelId, contextWindow);
return;
}
onSelectModel?.(modelId);
};
const prefEntryFor = (modelId: string): ComposerModelPrefEntry => (
previewProvider ? { providerId: previewProvider.id, modelId } : { modelId }
);
if (hasProviders && view === 'providers') {
return (
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
<button
type="button"
onClick={() => setView('models')}
className={rowClassName}
>
<ChevronLeft size={12} className="text-muted-foreground/60 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/70">
{t('ai.chat.providers')}
</span>
</button>
<div className="mx-2 my-1 border-t border-border/40" />
{providers.map((provider) => {
const isBound = provider.id === selectedProviderId;
return (
<button
key={provider.id}
type="button"
role="option"
aria-selected={isBound}
onClick={() => {
setPreviewProviderId(provider.id);
setQuery('');
setView('models');
}}
className={rowClassName}
>
<ProviderIconBadge provider={provider} size="xs" />
<span className="min-w-0 flex-1 truncate text-foreground/88">{provider.name}</span>
{isBound && <Check size={11} className="text-primary shrink-0" />}
</button>
);
})}
</div>
);
}
return (
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
{hasProviders && previewProvider && (
<>
<button
type="button"
aria-label={t('ai.chat.selectProvider')}
onClick={() => setView('providers')}
className={rowClassName}
>
<ProviderIconBadge provider={previewProvider} size="xs" />
<span className="min-w-0 flex-1 truncate text-foreground/88">{previewProvider.name}</span>
<ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
</button>
</>
)}
<div className="px-2 pb-1">
<div className="flex h-7 items-center gap-1.5 rounded-md bg-muted/40 px-2">
<Search size={11} className="text-muted-foreground/50 shrink-0" />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && trimmedQuery) {
event.preventDefault();
const nextId = resolveComposerEnterModelId({
query: trimmedQuery,
models,
grouped,
filtered,
showCustom,
});
if (nextId) selectModel(nextId);
}
}}
placeholder={t('ai.chat.searchModels')}
className="h-full w-full bg-transparent text-[12px] text-foreground/88 outline-none placeholder:text-muted-foreground/40"
/>
</div>
</div>
<div className="max-h-[280px] overflow-y-auto">
{catalog.loading && (
<div className="flex h-8 items-center gap-1.5 px-2.5 text-[11px] text-muted-foreground/55">
<Loader2 size={11} className="animate-spin" />
{t('ai.chat.loadingModels')}
</div>
)}
{showCustom && (
<button
type="button"
onClick={() => selectModel(trimmedQuery)}
className={rowClassName}
>
<Pin size={11} className="text-muted-foreground/55 shrink-0" />
<span className="min-w-0 truncate text-foreground/85">
{t('ai.chat.useCustomModel').replace('{id}', trimmedQuery)}
</span>
</button>
)}
{grouped.pinned.length > 0 && (
<>
<SectionLabel>{t('ai.chat.pinned')}</SectionLabel>
{grouped.pinned.map((model) => (
<ModelRow
key={`pin-${model.id}`}
model={model}
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
pinned
onSelect={() => selectModel(model.id)}
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
pinLabel={t('ai.chat.pinModel')}
unpinLabel={t('ai.chat.unpinModel')}
/>
))}
</>
)}
{grouped.recent.length > 0 && (
<>
<SectionLabel>{t('ai.chat.recent')}</SectionLabel>
{grouped.recent.map((model) => (
<ModelRow
key={`recent-${model.id}`}
model={model}
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
pinned={pinnedKeys.has(model.id)}
onSelect={() => selectModel(model.id)}
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
pinLabel={t('ai.chat.pinModel')}
unpinLabel={t('ai.chat.unpinModel')}
/>
))}
</>
)}
{(grouped.pinned.length > 0 || grouped.recent.length > 0) && grouped.rest.length > 0 && (
<SectionLabel>{t('ai.chat.models')}</SectionLabel>
)}
{grouped.rest.map((model) => (
<ModelRow
key={model.id}
model={model}
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
pinned={pinnedKeys.has(model.id)}
onSelect={() => selectModel(model.id)}
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
pinLabel={t('ai.chat.pinModel')}
unpinLabel={t('ai.chat.unpinModel')}
/>
))}
{!catalog.loading && filtered.length === 0 && !showCustom && (
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/50">
{catalog.error || t('ai.chat.noMatchingModels')}
</div>
)}
</div>
</div>
);
};
export default React.memo(ComposerModelPicker);