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`}