diff --git a/.changeset/model-database-copy-switch.md b/.changeset/model-database-copy-switch.md new file mode 100644 index 000000000..d60740857 --- /dev/null +++ b/.changeset/model-database-copy-switch.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +`/model-database` now does something useful with Enter: it copies the highlighted model's ID to the clipboard, and, when the active provider is OpenRouter, switches the session to that model immediately (same behavior as `/model`, including its own confirmation toast). The footer hint reflects which of the two Enter will do. Closes #1310. diff --git a/source/app/components/modal-selectors.tsx b/source/app/components/modal-selectors.tsx index 67e6b6780..2945f9c52 100644 --- a/source/app/components/modal-selectors.tsx +++ b/source/app/components/modal-selectors.tsx @@ -124,7 +124,13 @@ export function ModalSelectors({ } if (activeMode === 'modelDatabase') { - return ; + return ( + + ); } if (activeMode === 'configWizard') { diff --git a/source/commands/model-database.spec.tsx b/source/commands/model-database.spec.tsx index 65dc73be0..8fd7dfd66 100644 --- a/source/commands/model-database.spec.tsx +++ b/source/commands/model-database.spec.tsx @@ -1,4 +1,5 @@ import test from 'ava'; +import clipboard from 'clipboardy'; import React from 'react'; import {renderWithTheme} from '../test-utils/render-with-theme.js'; import {clearModelCache} from '../model-database/model-fetcher.js'; @@ -6,6 +7,27 @@ import {ModelDatabaseDisplay, modelDatabaseCommand} from './model-database.js'; console.log(`\nmodel-database.spec.tsx – ${React.version}`); +// clipboardy shells out to a platform clipboard tool that may not be present +// in CI/sandboxes — stub `write` directly, same approach copy.spec.tsx uses. +const originalWrite = clipboard.write; +let lastWritten: string | null = null; +let writeImpl: (text: string) => Promise = async text => { + lastWritten = text; +}; + +test.beforeEach(() => { + lastWritten = null; + writeImpl = async text => { + lastWritten = text; + }; + (clipboard as {write: (text: string) => Promise}).write = text => + writeImpl(text); +}); + +test.afterEach(() => { + (clipboard as {write: (text: string) => Promise}).write = originalWrite; +}); + // Helper to create mock OpenRouter API response function createMockOpenRouterResponse() { return { @@ -602,6 +624,42 @@ test.serial('ModelDatabaseDisplay: handles empty model list', async t => { } }); +test.serial( + 'ModelDatabaseDisplay: Enter still closes the panel when no model is highlighted', + async t => { + clearModelCache(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return { + ok: true, + json: async () => ({data: []}), + } as Response; + }; + + let cancelCalled = false; + const onCancel = () => { + cancelCalled = true; + }; + + try { + const {stdin} = renderWithTheme(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + stdin.write('\r'); + + await new Promise(resolve => setTimeout(resolve, 50)); + + t.true(cancelCalled); + t.is(lastWritten, null); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(); + } + }, +); + // ============================================================================ // Tab Switching Tests // ============================================================================ @@ -669,3 +727,235 @@ test.serial('ModelDatabaseDisplay: displays command title', async t => { clearModelCache(); } }); + +// ============================================================================ +// Copy/Switch on Enter Tests +// ============================================================================ + +// Mock data is sorted by `created` descending for the "latest" tab, so +// meta-llama/llama-3.1-70b (created 1710000000) is the highlighted model +// by default. +const HIGHLIGHTED_MODEL_ID = 'meta-llama/llama-3.1-70b'; + +test.serial( + 'ModelDatabaseDisplay: Enter copies the highlighted model ID to the clipboard', + async t => { + clearModelCache(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return { + ok: true, + json: async () => createMockOpenRouterResponse(), + } as Response; + }; + + try { + const {stdin} = renderWithTheme(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + stdin.write('\r'); + + await new Promise(resolve => setTimeout(resolve, 50)); + + t.is(lastWritten, HIGHLIGHTED_MODEL_ID); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(); + } + }, +); + +test.serial( + 'ModelDatabaseDisplay: Enter shows a copy confirmation and closes when not on OpenRouter', + async t => { + clearModelCache(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return { + ok: true, + json: async () => createMockOpenRouterResponse(), + } as Response; + }; + + let cancelCalled = false; + const onCancel = () => { + cancelCalled = true; + }; + + try { + const {stdin} = renderWithTheme( + , + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + stdin.write('\r'); + + await new Promise(resolve => setTimeout(resolve, 50)); + + t.is(lastWritten, HIGHLIGHTED_MODEL_ID); + t.true(cancelCalled); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(); + } + }, +); + +test.serial( + 'ModelDatabaseDisplay: Enter switches the model when the active provider is OpenRouter', + async t => { + clearModelCache(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return { + ok: true, + json: async () => createMockOpenRouterResponse(), + } as Response; + }; + + let cancelCalled = false; + const onCancel = () => { + cancelCalled = true; + }; + + const selectCalls: Array<[string, string]> = []; + const onModelSelect = async (provider: string, model: string) => { + selectCalls.push([provider, model]); + return undefined; + }; + + try { + const {stdin} = renderWithTheme( + , + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + stdin.write('\r'); + + await new Promise(resolve => setTimeout(resolve, 50)); + + t.is(lastWritten, HIGHLIGHTED_MODEL_ID); + t.deepEqual(selectCalls, [['openrouter', HIGHLIGHTED_MODEL_ID]]); + // Switching delegates closing to onModelSelect's own exitMode() — + // the component itself must not also call onCancel. + t.false(cancelCalled); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(); + } + }, +); + +test.serial( + 'ModelDatabaseDisplay: Enter passes through the configured provider casing, not a hardcoded literal', + async t => { + clearModelCache(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return { + ok: true, + json: async () => createMockOpenRouterResponse(), + } as Response; + }; + + const selectCalls: Array<[string, string]> = []; + const onModelSelect = async (provider: string, model: string) => { + selectCalls.push([provider, model]); + return undefined; + }; + + try { + // isOpenRouterProvider() matches "OpenRouter" case-insensitively, + // but the app's downstream handleModelSelect compares provider + // names with strict equality — so onModelSelect must receive the + // same casing that was configured, not a hardcoded 'openrouter'. + const {stdin} = renderWithTheme( + , + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + stdin.write('\r'); + + await new Promise(resolve => setTimeout(resolve, 50)); + + t.deepEqual(selectCalls, [['OpenRouter', HIGHLIGHTED_MODEL_ID]]); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(); + } + }, +); + +test.serial( + 'ModelDatabaseDisplay: footer hint mentions "Copy & Switch" when on OpenRouter', + async t => { + clearModelCache(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return { + ok: true, + json: async () => createMockOpenRouterResponse(), + } as Response; + }; + + try { + const {lastFrame} = renderWithTheme( + , + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const output = lastFrame(); + t.truthy(output); + t.true(output!.includes('Copy & Switch')); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(); + } + }, +); + +test.serial( + 'ModelDatabaseDisplay: footer hint says "Copy ID" when not on OpenRouter', + async t => { + clearModelCache(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + return { + ok: true, + json: async () => createMockOpenRouterResponse(), + } as Response; + }; + + try { + const {lastFrame} = renderWithTheme(); + + await new Promise(resolve => setTimeout(resolve, 200)); + + const output = lastFrame(); + t.truthy(output); + t.true(output!.includes('Copy ID')); + t.false(output!.includes('Copy & Switch')); + } finally { + globalThis.fetch = originalFetch; + clearModelCache(); + } + }, +); diff --git a/source/commands/model-database.tsx b/source/commands/model-database.tsx index f37307455..bcda6cda7 100644 --- a/source/commands/model-database.tsx +++ b/source/commands/model-database.tsx @@ -1,6 +1,8 @@ +import clipboard from 'clipboardy'; import {Box, Text, useFocus, useInput} from 'ink'; import {Tab, Tabs} from 'ink-tab'; import React, {useEffect, useState} from 'react'; +import {isOpenRouterProvider} from '@/ai-sdk-client/providers/openrouter'; import {TitledBoxWithPreferences} from '@/components/ui/titled-box'; import { COST_SCORE_CHEAP, @@ -12,14 +14,22 @@ import {useTerminalWidth} from '@/hooks/useTerminalWidth'; import {useTheme} from '@/hooks/useTheme'; import {modelMatchingEngine} from '@/model-database/model-engine'; import {Colors, Command, ModelEntry} from '@/types/index'; +import {errorMsg, successMsg} from '@/utils/message-factory'; +import {addToMessageQueue} from '@/utils/message-queue'; type TabType = 'latest' | 'open' | 'proprietary'; interface ModelDatabaseDisplayProps { onCancel?: () => void; + currentProvider?: string; + onModelSelect?: (provider: string, model: string) => Promise; } -function ModelDatabaseDisplay({onCancel}: ModelDatabaseDisplayProps) { +function ModelDatabaseDisplay({ + onCancel, + currentProvider, + onModelSelect, +}: ModelDatabaseDisplayProps) { const boxWidth = useTerminalWidth(); const {colors} = useTheme(); const [openModels, setOpenModels] = useState([]); @@ -82,9 +92,59 @@ function ModelDatabaseDisplay({onCancel}: ModelDatabaseDisplayProps) { } } } else if (key.return) { - setClosed(true); - if (onCancel) { - onCancel(); + const model = currentTabModels[currentModelIndex]; + if (!model) { + setClosed(true); + if (onCancel) { + onCancel(); + } + return; + } + + const switchProvider = + currentProvider && + isOpenRouterProvider(currentProvider) && + onModelSelect + ? currentProvider + : undefined; + + const reportClipboardError = (error: unknown) => { + const detail = error instanceof Error ? error.message : String(error); + addToMessageQueue( + errorMsg( + `Failed to copy model ID to clipboard: ${detail}`, + 'model-database-copy', + ), + ); + }; + + if (switchProvider) { + // handleModelSelect (behind onModelSelect) shows its own + // "Model changed to: X" toast and exits the mode on success - + // don't also setClosed/onCancel here. Pass switchProvider + // (the configured currentProvider, not a hardcoded + // 'openrouter' literal): handleModelSelect compares it to the + // live provider name with strict equality, so a + // differently-cased configured name (e.g. "OpenRouter") + // would otherwise be misdetected as a provider change. + void clipboard.write(model.id).catch(reportClipboardError); + void onModelSelect?.(switchProvider, model.id); + } else { + void clipboard + .write(model.id) + .then(() => { + addToMessageQueue( + successMsg( + `Copied model ID to clipboard: ${model.id}`, + 'model-database-copy', + ), + ); + }) + .catch(reportClipboardError); + setClosed(true); + if (onCancel) { + onCancel(); + } } } else if (key.upArrow) { setCurrentModelIndex(prev => Math.max(0, prev - 1)); @@ -218,7 +278,11 @@ function ModelDatabaseDisplay({onCancel}: ModelDatabaseDisplayProps) { {searchMode ? 'Type to search | Backspace to delete | Up/Down: Navigate | Esc: Exit search' - : 'Type to search | Up/Down: Navigate | Tab: Switch tabs | Esc: Close'} + : `Type to search | Up/Down: Navigate | Enter: ${ + currentProvider && isOpenRouterProvider(currentProvider) + ? 'Copy & Switch' + : 'Copy ID' + } | Tab: Switch tabs | Esc: Close`}