diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9cf7e7d1af4..83cb4a2cd78 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -302,6 +302,10 @@ const LAST_EDITOR_KEY = "t3code:last-editor"; const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const MODEL_PICKER_FAVORITES_KEY = "t3code:model-picker-favorites:v1"; const THREAD_CONTEXT_PANEL_PINNED_KEY = "t3code:thread-context-panel-pinned"; +const THREAD_CONTEXT_PANEL_PROGRESS_COLLAPSED_KEY = + "t3code:thread-context-panel-progress-collapsed"; +const THREAD_CONTEXT_PANEL_ENVIRONMENT_COLLAPSED_KEY = + "t3code:thread-context-panel-environment-collapsed"; const THREAD_CONTEXT_PANEL_ARTIFACTS_COLLAPSED_KEY = "t3code:thread-context-panel-artifacts-collapsed"; const THREAD_CONTEXT_PANEL_SOURCES_COLLAPSED_KEY = "t3code:thread-context-panel-sources-collapsed"; @@ -434,6 +438,33 @@ const THREAD_CONTEXT_ARTIFACT_EXTENSIONS = new Set([ "xls", "xlsx", ]); +const THREAD_CONTEXT_ARTIFACT_COLLECT_LIMIT = 30; +const THREAD_CONTEXT_ARTIFACT_PAGE_SIZE = 5; +const THREAD_CONTEXT_MARKDOWN_ARTIFACT_EXTENSIONS = new Set(["md", "mdown", "mdx", "mkd"]); +const THREAD_CONTEXT_LOW_VALUE_ARTIFACT_FILENAMES = new Set([ + "agents.md", + "authors.md", + "changelog.md", + "code_of_conduct.md", + "contributing.md", + "license.md", + "readme.md", + "readme.mdx", + "security.md", + "support.md", +]); +const THREAD_CONTEXT_IGNORED_ARTIFACT_SEGMENTS = new Set([ + ".git", + ".next", + ".turbo", + ".vite", + "coverage", + "node_modules", + "playwright-report", + "test-results", +]); +const THREAD_CONTEXT_DELIVERABLE_MARKDOWN_PATTERN = + /\b(analysis|architecture|audit|brief|design|guide|notes|plan|postmortem|proposal|prd|report|requirements|research|review|roadmap|runbook|spec|summary)\b/i; const CODEX_REASONING_LABEL_BY_OPTION: Record = { low: "Low", medium: "Medium", @@ -496,6 +527,111 @@ function isThreadContextArtifactPath(pathValue: string): boolean { return THREAD_CONTEXT_ARTIFACT_EXTENSIONS.has(extensionOf(pathValue)); } +function isIgnoredThreadContextArtifactPath(pathValue: string): boolean { + const normalizedPath = normalizeThreadContextPath(pathValue).toLowerCase(); + const fileName = basenameOfPath(normalizedPath); + if (THREAD_CONTEXT_LOW_VALUE_ARTIFACT_FILENAMES.has(fileName)) { + return true; + } + return normalizedPath + .split("/") + .some((segment) => THREAD_CONTEXT_IGNORED_ARTIFACT_SEGMENTS.has(segment)); +} + +function isDeliverableMarkdownArtifactPath(pathValue: string): boolean { + const extension = extensionOf(pathValue); + if (!THREAD_CONTEXT_MARKDOWN_ARTIFACT_EXTENSIONS.has(extension)) { + return true; + } + return THREAD_CONTEXT_DELIVERABLE_MARKDOWN_PATTERN.test(basenameOfPath(pathValue)); +} + +function shouldShowThreadContextArtifactPath( + pathValue: string, + source: "diff" | "generated" | "message", +): boolean { + if (!isThreadContextArtifactPath(pathValue) || isIgnoredThreadContextArtifactPath(pathValue)) { + return false; + } + return source !== "diff" || isDeliverableMarkdownArtifactPath(pathValue); +} + +function threadContextTimestampMs(value: string | null | undefined): number { + if (!value) { + return 0; + } + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function isThreadContextOnOrAfterBoundary( + createdAt: string | null | undefined, + boundary: string | null, +): boolean { + if (!boundary) { + return true; + } + return threadContextTimestampMs(createdAt) >= threadContextTimestampMs(boundary); +} + +function threadContextArtifactIdentity(pathValue: string, cwd?: string | undefined): string { + const normalizedPath = normalizeThreadContextPath(pathValue).toLowerCase(); + const normalizedCwd = cwd ? normalizeThreadContextPath(cwd).toLowerCase() : ""; + return `${normalizedCwd}\u0000${normalizedPath}`; +} + +function asThreadContextRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function stringValueFromThreadContextPayload( + payload: Record, + keys: ReadonlyArray, +): string | null { + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +function normalizedThreadContextToolName(payload: Record): string | null { + const toolName = stringValueFromThreadContextPayload(payload, [ + "toolName", + "tool_name", + "name", + "title", + "itemName", + ]); + return ( + toolName + ?.toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") ?? null + ); +} + +function isThreadContextBrowserToolName(toolName: string | null): boolean { + return ( + toolName === "browser" || + toolName === "browser_use" || + toolName === "control_in_app_browser" || + toolName?.startsWith("browser_") === true || + toolName?.includes("_browser_") === true + ); +} + +function isThreadContextWebSearchToolName(toolName: string | null): boolean { + return ( + toolName === "web_search" || + toolName === "websearch" || + toolName === "search_web" || + toolName === "searchweb" + ); +} + function isBrowserPreviewArtifactPath(pathValue: string): boolean { const extension = extensionOf(pathValue); return extension === "html" || extension === "htm"; @@ -780,70 +916,110 @@ function collectThreadContextArtifacts(input: { thread: Thread; homeDirectory: string | undefined; }): ThreadContextArtifact[] { - const seen = new Set(); - const artifacts: ThreadContextArtifact[] = []; - const pushArtifact = (pathValue: string, kind: ThreadContextArtifact["kind"]) => { + const boundary = latestUserMessageCreatedAt(input.thread); + const candidates: Array< + ThreadContextArtifact & { + identityKey: string; + labelKey: string; + priority: number; + createdAt: string; + } + > = []; + const pushArtifact = (inputArtifact: { + pathValue: string; + kind: ThreadContextArtifact["kind"]; + source: "diff" | "generated" | "message"; + createdAt: string; + cwd?: string | undefined; + label?: string | undefined; + }) => { + const { pathValue, kind, source, createdAt, cwd, label } = inputArtifact; const normalizedPath = normalizeThreadContextPath(pathValue); - const dedupeKey = normalizedPath.toLowerCase(); - if (!isThreadContextArtifactPath(normalizedPath) || seen.has(dedupeKey)) { + const normalizedCwd = cwd ? normalizeThreadContextPath(cwd) : undefined; + if (!shouldShowThreadContextArtifactPath(normalizedPath, source)) { return; } - seen.add(dedupeKey); - artifacts.push({ + const displayLabel = label ?? basenameOfPath(normalizedPath); + candidates.push({ path: normalizedPath, - label: basenameOfPath(normalizedPath), + label: displayLabel, kind, + priority: source === "generated" ? 3 : source === "message" ? 2 : 1, + identityKey: threadContextArtifactIdentity(normalizedPath, normalizedCwd), + labelKey: displayLabel.toLowerCase(), + createdAt, + ...(normalizedCwd ? { cwd: normalizedCwd } : {}), }); }; let generatedImageCount = 0; const pushGeneratedImageArtifact = (artifact: { path: string; label: string; + createdAt: string; cwd?: string | undefined; }) => { const normalizedPath = normalizeThreadContextPath(artifact.path); - const normalizedCwd = artifact.cwd ? normalizeThreadContextPath(artifact.cwd) : undefined; - const dedupeKey = `${normalizedCwd ?? ""}\u0000${normalizedPath}`.toLowerCase(); - if (!isThreadContextArtifactPath(normalizedPath) || seen.has(dedupeKey)) { + if (!shouldShowThreadContextArtifactPath(normalizedPath, "generated")) { return; } - seen.add(dedupeKey); generatedImageCount += 1; const label = artifact.label === "Generated image" ? `Generated image ${generatedImageCount}` : artifact.label; - artifacts.push({ - path: normalizedPath, + pushArtifact({ + pathValue: normalizedPath, label, kind: "generated", - ...(normalizedCwd ? { cwd: normalizedCwd } : {}), + source: "generated", + createdAt: artifact.createdAt, + ...(artifact.cwd ? { cwd: artifact.cwd } : {}), }); }; for (const summary of input.thread.turnDiffSummaries) { + if (!isThreadContextOnOrAfterBoundary(summary.completedAt, boundary)) { + continue; + } for (const file of summary.files) { - pushArtifact(file.path, "workspace"); + pushArtifact({ + pathValue: file.path, + kind: "workspace", + source: "diff", + createdAt: summary.completedAt, + }); } } for (const message of input.thread.messages) { + if ( + message.role !== "assistant" || + !isThreadContextOnOrAfterBoundary(message.createdAt, boundary) + ) { + continue; + } const pathPattern = /(?:^|[\s`"'(])((?:[A-Za-z]:[\\/])?(?:[\w .@()[\]-]+[\\/])*[\w .@()[\]-]+\.(?:avif|bmp|docx|gif|heic|jpe?g|md|mdown|mdx|mkd|pdf|png|pptx|webp|xls|xlsx))(?=$|[\s`"',).])/gi; for (const match of message.text.matchAll(pathPattern)) { const pathValue = match[1]; if (pathValue) { - pushArtifact(pathValue, "workspace"); + pushArtifact({ + pathValue, + kind: "workspace", + source: "message", + createdAt: message.completedAt ?? message.createdAt, + }); } } } for (const activity of input.thread.activities) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; + if (!isThreadContextOnOrAfterBoundary(activity.createdAt, boundary)) { + continue; + } + const payload = asThreadContextRecord(activity.payload); for (const artifact of extractGeneratedImageArtifacts(payload)) { pushGeneratedImageArtifact({ ...artifact, + createdAt: activity.createdAt, ...(artifact.cwd ? { cwd: artifact.cwd } : artifact.providerThreadId && input.homeDirectory @@ -857,7 +1033,38 @@ function collectThreadContextArtifacts(input: { } } - return artifacts; + candidates.sort((left, right) => { + if (right.priority !== left.priority) { + return right.priority - left.priority; + } + const timeDelta = + threadContextTimestampMs(right.createdAt) - threadContextTimestampMs(left.createdAt); + if (timeDelta !== 0) { + return timeDelta; + } + return left.label.localeCompare(right.label); + }); + + const selected: ThreadContextArtifact[] = []; + const seenIdentities = new Set(); + const seenLabels = new Set(); + for (const candidate of candidates) { + if (seenIdentities.has(candidate.identityKey) || seenLabels.has(candidate.labelKey)) { + continue; + } + seenIdentities.add(candidate.identityKey); + seenLabels.add(candidate.labelKey); + selected.push({ + path: candidate.path, + label: candidate.label, + kind: candidate.kind, + ...(candidate.cwd ? { cwd: candidate.cwd } : {}), + }); + if (selected.length >= THREAD_CONTEXT_ARTIFACT_COLLECT_LIMIT) { + break; + } + } + return selected; } function collectThreadContextSources(thread: Thread): ThreadContextSource[] { @@ -865,29 +1072,33 @@ function collectThreadContextSources(thread: Thread): ThreadContextSource[] { const addSource = (source: ThreadContextSource) => { sources.set(source.id, source); }; + const boundary = latestUserMessageCreatedAt(thread); for (const message of thread.messages) { - if ((message.attachments?.length ?? 0) > 0) { + if ( + isThreadContextOnOrAfterBoundary(message.createdAt, boundary) && + (message.attachments?.length ?? 0) > 0 + ) { addSource({ id: "attachments", label: "Attachments", icon: "file" }); } } for (const activity of thread.activities) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : {}; + if (!isThreadContextOnOrAfterBoundary(activity.createdAt, boundary)) { + continue; + } + const payload = asThreadContextRecord(activity.payload) ?? {}; const itemType = typeof payload.itemType === "string" ? payload.itemType : ""; - const summary = `${activity.summary} ${JSON.stringify(payload)}`.toLowerCase(); - if (itemType === "web_search" || summary.includes("web search")) { + const toolName = normalizedThreadContextToolName(payload); + if (itemType === "web_search" || isThreadContextWebSearchToolName(toolName)) { addSource({ id: "web-search", label: "Web search", icon: "web" }); } - if (summary.includes("browser")) { + if ( + (itemType === "mcp_tool_call" || itemType === "dynamic_tool_call") && + isThreadContextBrowserToolName(toolName) + ) { addSource({ id: "browser", label: "Browser", icon: "browser" }); } - if (itemType === "file_change" || summary.includes("read") || summary.includes("file")) { - addSource({ id: "files", label: "Files", icon: "file" }); - } } return [...sources.values()]; @@ -7440,36 +7651,46 @@ function persistThreadContextPanelPreference(key: string, value: boolean): void function ThreadContextCollapsibleSection(props: { title: string; - count: number; + count?: number; collapsed: boolean; - emptyLabel: string; + emptyLabel?: string; onToggle: () => void; + contentClassName?: string; + action?: ReactNode; children: ReactNode; }) { - const { title, count, collapsed, emptyLabel, onToggle, children } = props; + const { title, count, collapsed, emptyLabel, onToggle, contentClassName, action, children } = + props; return (
- - {collapsed ? null : count > 0 ? ( -
{children}
- ) : ( + + {action ?
{action}
: null} +
+ {collapsed ? null : count === 0 && emptyLabel ? (

{emptyLabel}

+ ) : ( +
+ {children} +
)} ); @@ -7575,12 +7796,21 @@ const ThreadContextPanel = memo(function ThreadContextPanel({ } return localStorage.getItem(THREAD_CONTEXT_PANEL_PINNED_KEY) !== "false"; }); + const [progressCollapsed, setProgressCollapsed] = useState(() => + readThreadContextPanelPreference(THREAD_CONTEXT_PANEL_PROGRESS_COLLAPSED_KEY, false), + ); + const [environmentCollapsed, setEnvironmentCollapsed] = useState(() => + readThreadContextPanelPreference(THREAD_CONTEXT_PANEL_ENVIRONMENT_COLLAPSED_KEY, false), + ); const [artifactsCollapsed, setArtifactsCollapsed] = useState(() => readThreadContextPanelPreference(THREAD_CONTEXT_PANEL_ARTIFACTS_COLLAPSED_KEY, false), ); const [sourcesCollapsed, setSourcesCollapsed] = useState(() => readThreadContextPanelPreference(THREAD_CONTEXT_PANEL_SOURCES_COLLAPSED_KEY, false), ); + const [visibleArtifactCount, setVisibleArtifactCount] = useState( + THREAD_CONTEXT_ARTIFACT_PAGE_SIZE, + ); const [branchMenuOpen, setBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const [usageOpen, setUsageOpen] = useState(false); @@ -7596,7 +7826,16 @@ const ThreadContextPanel = memo(function ThreadContextPanel({ [homeDirectory, thread], ); const sources = useMemo(() => collectThreadContextSources(thread), [thread]); + useEffect(() => { + setVisibleArtifactCount(THREAD_CONTEXT_ARTIFACT_PAGE_SIZE); + }, [activeThreadId]); const hasGitContext = gitCwd !== null; + const hasProgress = progressItems.length > 0; + const hasArtifacts = artifacts.length > 0; + const hasSources = sources.length > 0; + const displayedArtifacts = artifacts.slice(0, visibleArtifactCount); + const remainingArtifactCount = Math.max(0, artifacts.length - displayedArtifacts.length); + const showMoreArtifactCount = Math.min(THREAD_CONTEXT_ARTIFACT_PAGE_SIZE, remainingArtifactCount); const hasChanges = (gitStatus?.workingTree.insertions ?? 0) > 0 || (gitStatus?.workingTree.deletions ?? 0) > 0; const localBranches = useMemo( @@ -7689,6 +7928,20 @@ const ThreadContextPanel = memo(function ThreadContextPanel({ return next; }); }, []); + const toggleProgressCollapsed = useCallback(() => { + setProgressCollapsed((current) => { + const next = !current; + persistThreadContextPanelPreference(THREAD_CONTEXT_PANEL_PROGRESS_COLLAPSED_KEY, next); + return next; + }); + }, []); + const toggleEnvironmentCollapsed = useCallback(() => { + setEnvironmentCollapsed((current) => { + const next = !current; + persistThreadContextPanelPreference(THREAD_CONTEXT_PANEL_ENVIRONMENT_COLLAPSED_KEY, next); + return next; + }); + }, []); const toggleArtifactsCollapsed = useCallback(() => { setArtifactsCollapsed((current) => { const next = !current; @@ -7703,85 +7956,96 @@ const ThreadContextPanel = memo(function ThreadContextPanel({ return next; }); }, []); + const showMoreArtifacts = useCallback(() => { + setVisibleArtifactCount((current) => + Math.min(current + THREAD_CONTEXT_ARTIFACT_PAGE_SIZE, artifacts.length), + ); + }, [artifacts.length]); + + const pinControl = ( + + ); + const showPinWithProgress = hasProgress; + const showPinWithEnvironment = !showPinWithProgress && hasGitContext; + const showPinWithArtifacts = !showPinWithProgress && !hasGitContext && hasArtifacts; + const showPinWithSources = !showPinWithProgress && !hasGitContext && !hasArtifacts && hasSources; + + if (!hasProgress && !hasGitContext && !hasArtifacts && !hasSources) { + return null; + } const panel = ( ); diff --git a/apps/web/src/components/SettingsRouteView.tsx b/apps/web/src/components/SettingsRouteView.tsx index 8ee31182872..247bf4f1a28 100644 --- a/apps/web/src/components/SettingsRouteView.tsx +++ b/apps/web/src/components/SettingsRouteView.tsx @@ -57,6 +57,7 @@ import { normalizeSettingsSectionId, SETTINGS_SECTION_IDS, SETTINGS_SIDEBAR_SECTIONS, + type SettingsSectionId, } from "../settingsSections"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; @@ -166,8 +167,26 @@ const BROWSING_DATA_OPTIONS: ReadonlyArray<{ { value: "siteData", label: "Clear site data" }, ]; -const SETTINGS_SECTION_CLASS = - "rounded-xl bg-card/80 p-5 shadow-[0_1px_0_rgba(255,255,255,0.02)]"; +const SETTINGS_SECTION_DESCRIPTIONS: Record = { + [SETTINGS_SECTION_IDS.appearance]: "Match your workspace to the environment you are in.", + [SETTINGS_SECTION_IDS.models]: "Tune the model picker and suggestion behavior for new work.", + [SETTINGS_SECTION_IDS.remoteAccess]: "Pair trusted clients and control how this backend is reached.", + [SETTINGS_SECTION_IDS.responses]: "Control how assistant output appears while work is running.", + [SETTINGS_SECTION_IDS.browserUse]: "Set how the browser stores state and asks for approval.", + [SETTINGS_SECTION_IDS.computerUse]: "Control desktop automation access and app permissions.", + [SETTINGS_SECTION_IDS.keybindings]: "Open and manage the keyboard shortcuts file.", + [SETTINGS_SECTION_IDS.safety]: "Choose when destructive thread actions ask for confirmation.", + [SETTINGS_SECTION_IDS.providers]: "Manage agent runtimes, accounts, and local binaries.", + [SETTINGS_SECTION_IDS.archived]: "Restore archived threads or remove them permanently.", +}; + +const SETTINGS_SECTION_CLASS = "space-y-4"; +const SETTINGS_GROUP_CLASS = "rounded-xl bg-card/45 p-5 shadow-[0_1px_0_rgba(255,255,255,0.03)]"; +const SETTINGS_SUBGROUP_CLASS = "rounded-xl bg-background/35 p-4"; +const SETTINGS_ROW_CLASS = + "flex items-center justify-between gap-4 rounded-lg bg-background/35 px-3 py-3"; +const SETTINGS_EMPTY_STATE_CLASS = + "rounded-lg bg-background/35 px-4 py-4 text-sm text-muted-foreground"; const EMPTY_COMPUTER_USE_APPS: ComputerUseAppSummary[] = []; const COMPUTER_USE_PERMISSION_SWITCH_CLASS = "justify-self-end [&_[data-slot=switch-thumb]]:transition-none [&_[data-slot=switch-thumb]]:will-change-auto"; @@ -464,6 +483,7 @@ function SettingsRouteView() { }); const activeSectionLabel = SETTINGS_SIDEBAR_SECTIONS.find((section) => section.id === activeSection)?.label ?? "Settings"; + const activeSectionDescription = SETTINGS_SECTION_DESCRIPTIONS[activeSection]; const usesDesktopAppChrome = isElectronRuntime(); const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); @@ -677,29 +697,14 @@ function SettingsRouteView() { [computerUseApps], ); - const providers = serverConfigQuery.data?.providers ?? []; - const codexStatus = useMemo( - () => providers.find((p) => p.provider === "codex"), - [providers], - ); - const opencodeStatus = useMemo( - () => providers.find((p) => p.provider === "opencode"), - [providers], - ); - const claudeStatus = useMemo( - () => providers.find((p) => p.provider === "claudeAgent"), - [providers], - ); + const providers = serverConfigQuery.data?.providers; + const codexStatus = providers?.find((p) => p.provider === "codex"); + const opencodeStatus = providers?.find((p) => p.provider === "opencode"); + const claudeStatus = providers?.find((p) => p.provider === "claudeAgent"); - const providerAccounts = serverConfigQuery.data?.providerAccounts ?? []; - const codexAccount = useMemo( - () => providerAccounts.find((a) => a.provider === "codex"), - [providerAccounts], - ); - const claudeAccount = useMemo( - () => providerAccounts.find((a) => a.provider === "claudeAgent"), - [providerAccounts], - ); + const providerAccounts = serverConfigQuery.data?.providerAccounts; + const codexAccount = providerAccounts?.find((a) => a.provider === "codex"); + const claudeAccount = providerAccounts?.find((a) => a.provider === "claudeAgent"); const claudeAuthenticationLabel = claudeAccount?.state === "authenticated" ? claudeAccount.account && "email" in claudeAccount.account @@ -1211,36 +1216,26 @@ function SettingsRouteView() {
+ />
-
- {activeSection !== SETTINGS_SECTION_IDS.remoteAccess ? ( -
-

- {activeSectionLabel} -

-

- Configure app-level preferences for this device. -

-
- ) : null} +
+
+

+ {activeSectionLabel} +

+

+ {activeSectionDescription} +

+
{activeSection === SETTINGS_SECTION_IDS.appearance ? (
-
-

Appearance

-

- Choose how T3 Code handles light and dark mode. -

-
-
{THEME_OPTIONS.map((option) => { const selected = theme === option.value; @@ -1250,10 +1245,10 @@ function SettingsRouteView() { type="button" role="radio" aria-checked={selected} - className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ + className={`flex w-full items-start justify-between rounded-lg px-3 py-3 text-left transition-colors ${ selected - ? "border-primary/60 bg-primary/8 text-foreground" - : "border-border bg-background text-muted-foreground hover:bg-accent" + ? "bg-primary/10 text-foreground shadow-[inset_0_0_0_1px_hsl(var(--primary)/0.38)]" + : "bg-background/35 text-muted-foreground hover:bg-accent/45" }`} onClick={() => setTheme(option.value)} > @@ -1271,7 +1266,7 @@ function SettingsRouteView() { })}
-

+

Active theme: {resolvedTheme}

@@ -1279,14 +1274,6 @@ function SettingsRouteView() { {activeSection === SETTINGS_SECTION_IDS.models ? (
-
-

Models

-

- Save additional provider model slugs so they appear in the chat model picker and - `/model` command suggestions. -

-
-