feat: dual select (provider + model) with default[provider] model default
The inline prompt now renders two <select> boxes:
- Provider: lists every provider from the cached /config/providers
response. Default = first provider.
- Model: lists that provider's model keys (Object.keys of the Record).
Default = default[providerID] (from the same response) when that
modelID is present in the provider's models; otherwise the first
model. Changing the provider select rebuilds the model select with
the new provider's models and its own default[provider].
On submit the two select values are read separately and passed as
providerId / modelId to the OpenCodeRequest (no more '|' delimiter).
The /opencode-bridge/edit body shape is unchanged (providerId, modelId).
Also fix the index.ts startup console log (p.models is a Record, use
Object.values), and drop the unused _cell field on the prompt widget
(now an unused param).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e7579d3779
commit
04f90ab5a0
@@ -181,17 +181,23 @@ describe('OpenCodeCellActions', () => {
|
||||
expect(model.stateChanged.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a model selector with the first option selected when providers are cached', () => {
|
||||
it('renders provider and model selects; model default uses default[provider] when it matches', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
models: {
|
||||
'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' }
|
||||
'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' },
|
||||
'claude-haiku-4-5': { id: 'claude-haiku-4-5' }
|
||||
}
|
||||
},
|
||||
{ id: 'openai', models: { 'gpt-5': { id: 'gpt-5' } } }
|
||||
]
|
||||
],
|
||||
default: {
|
||||
anthropic: 'claude-sonnet-4-20250514',
|
||||
openai: 'gpt-5'
|
||||
}
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
@@ -201,15 +207,103 @@ describe('OpenCodeCellActions', () => {
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const select = cell.node.querySelector(
|
||||
const prompt = cell.node.querySelector(
|
||||
'.opencode-inline-prompt'
|
||||
) as HTMLElement;
|
||||
const providerSel = prompt.querySelector(
|
||||
'.opencode-provider-select'
|
||||
) as HTMLSelectElement;
|
||||
const modelSel = prompt.querySelector(
|
||||
'.opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
expect(providerSel).not.toBeNull();
|
||||
expect(modelSel).not.toBeNull();
|
||||
|
||||
// Provider: first provider selected by default.
|
||||
expect(providerSel.options.length).toBe(2);
|
||||
expect(providerSel.options[0].value).toBe('anthropic');
|
||||
expect(providerSel.options[0].textContent).toContain('anthropic');
|
||||
expect(providerSel.options[0].textContent).toContain('Anthropic');
|
||||
expect(providerSel.value).toBe('anthropic');
|
||||
|
||||
// Model: options are that provider's model keys, and the default
|
||||
// matches default['anthropic'] = 'claude-sonnet-4-20250514'.
|
||||
expect(modelSel.options.length).toBe(2);
|
||||
expect(modelSel.options[0].value).toBe('claude-sonnet-4-20250514');
|
||||
expect(modelSel.options[1].value).toBe('claude-haiku-4-5');
|
||||
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
||||
|
||||
setOpenCodeProviders(null);
|
||||
});
|
||||
|
||||
it('falls back to the first model when default[provider] is not in models', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{ id: 'bailian', models: { 'kimi/kimi-k2.7-code': { id: 'kimi/kimi-k2.7-code' } } }
|
||||
],
|
||||
// default['bailian'] references a modelID not present in models.
|
||||
default: { bailian: 'qwen3.7-plus' }
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const modelSel = cell.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
expect(select).not.toBeNull();
|
||||
expect(select.options.length).toBe(2);
|
||||
expect(select.options[0].textContent).toBe('anthropic / claude-sonnet-4-20250514');
|
||||
expect(select.options[1].textContent).toBe('openai / gpt-5');
|
||||
// v3-final: default = first option (no settings).
|
||||
expect(select.value).toBe('anthropic|claude-sonnet-4-20250514');
|
||||
expect(modelSel.value).toBe('kimi/kimi-k2.7-code');
|
||||
setOpenCodeProviders(null);
|
||||
});
|
||||
|
||||
it('rebuilds the model select when the provider select changes', () => {
|
||||
setOpenCodeProviders({
|
||||
providers: [
|
||||
{
|
||||
id: 'anthropic',
|
||||
models: { 'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' } }
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
models: { 'gpt-5': { id: 'gpt-5' } }
|
||||
}
|
||||
],
|
||||
default: { openai: 'gpt-5' }
|
||||
});
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
const prompt = cell.node.querySelector(
|
||||
'.opencode-inline-prompt'
|
||||
) as HTMLElement;
|
||||
const providerSel = prompt.querySelector(
|
||||
'.opencode-provider-select'
|
||||
) as HTMLSelectElement;
|
||||
const modelSel = prompt.querySelector(
|
||||
'.opencode-model-select'
|
||||
) as HTMLSelectElement;
|
||||
|
||||
// Initial: anthropic, its only model.
|
||||
expect(providerSel.value).toBe('anthropic');
|
||||
expect(modelSel.options.length).toBe(1);
|
||||
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
||||
|
||||
// Switch to openai: model select rebuilds with openai's model and
|
||||
// default['openai'] = 'gpt-5' is in openai's models so it matches.
|
||||
providerSel.value = 'openai';
|
||||
providerSel.dispatchEvent(new Event('change'));
|
||||
expect(modelSel.options.length).toBe(1);
|
||||
expect(modelSel.options[0].value).toBe('gpt-5');
|
||||
expect(modelSel.value).toBe('gpt-5');
|
||||
|
||||
setOpenCodeProviders(null);
|
||||
});
|
||||
|
||||
@@ -223,6 +317,9 @@ describe('OpenCodeCellActions', () => {
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
expect(
|
||||
cell.node.querySelector('.opencode-inline-prompt .opencode-provider-select')
|
||||
).toBeNull();
|
||||
expect(
|
||||
cell.node.querySelector('.opencode-inline-prompt .opencode-model-select')
|
||||
).toBeNull();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||
* Renders a model selector, a textarea, and send/cancel buttons. The owning
|
||||
* cell is passed so we can resolve the cell context at submit time. submit
|
||||
* and cancel are callbacks owned by OpenCodeCellActions.
|
||||
* Renders two <select> boxes (provider + model), a textarea, and send/cancel
|
||||
* buttons. The model select's default for the chosen provider is
|
||||
* `default[providerID]` (from the /config/providers response) when that
|
||||
* modelID is present in the provider's models; otherwise the first model.
|
||||
*
|
||||
* v3-final: the model selector is fully dynamic — it lists every
|
||||
* (provider, model) pair from the providers cache and defaults to the first
|
||||
* option. No settings-based default.
|
||||
* v3-final: no settings — picks come from the OpenCode Serve providers cache.
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
@@ -20,11 +19,56 @@ export interface IOpenCodeInlinePromptOptions {
|
||||
providers: OpenCodeProvidersResponse | null;
|
||||
}
|
||||
|
||||
interface FlatProvider {
|
||||
id: string;
|
||||
name?: string;
|
||||
models: { [modelID: string]: unknown };
|
||||
}
|
||||
|
||||
function flattenProviders(
|
||||
providers: OpenCodeProvidersResponse | null
|
||||
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
|
||||
const out: FlatProvider[] = [];
|
||||
if (!providers || !providers.providers) {
|
||||
return { providers: out, defaultMap: (providers && providers.default) || {} };
|
||||
}
|
||||
for (const p of providers.providers) {
|
||||
if (!p.models || typeof p.models !== 'object') {
|
||||
continue;
|
||||
}
|
||||
out.push({ id: p.id, name: p.name, models: p.models });
|
||||
}
|
||||
return { providers: out, defaultMap: providers.default || {} };
|
||||
}
|
||||
|
||||
function modelKeys(models: { [modelID: string]: unknown }): string[] {
|
||||
return Object.keys(models);
|
||||
}
|
||||
|
||||
function pickDefaultModelId(
|
||||
providerId: string,
|
||||
models: { [modelID: string]: unknown },
|
||||
defaultMap: { [pid: string]: string }
|
||||
): string | undefined {
|
||||
const keys = modelKeys(models);
|
||||
if (keys.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const fromDefault = defaultMap[providerId];
|
||||
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
|
||||
return fromDefault;
|
||||
}
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
export class OpenCodeInlinePrompt extends Widget {
|
||||
private _textarea: HTMLTextAreaElement;
|
||||
private _sendBtn: HTMLButtonElement;
|
||||
private _cancelBtn: HTMLButtonElement;
|
||||
private _select: HTMLSelectElement | null = null;
|
||||
private _providerSelect: HTMLSelectElement | null = null;
|
||||
private _modelSelect: HTMLSelectElement | null = null;
|
||||
private _providerModels: FlatProvider[];
|
||||
private _defaultMap: { [pid: string]: string };
|
||||
|
||||
constructor(
|
||||
_cell: CodeCell,
|
||||
@@ -34,17 +78,28 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
this.addClass('opencode-inline-prompt');
|
||||
|
||||
const flat = flattenProviders(options.providers);
|
||||
if (flat.length > 0) {
|
||||
this._select = document.createElement('select');
|
||||
this._select.className = 'opencode-model-select';
|
||||
for (const opt of flat) {
|
||||
this._providerModels = flat.providers;
|
||||
this._defaultMap = flat.defaultMap;
|
||||
|
||||
if (this._providerModels.length > 0) {
|
||||
this._providerSelect = document.createElement('select');
|
||||
this._providerSelect.className = 'opencode-provider-select';
|
||||
for (const p of this._providerModels) {
|
||||
const o = document.createElement('option');
|
||||
o.value = `${opt.providerId}|${opt.modelId}`;
|
||||
o.textContent = `${opt.providerId} / ${opt.modelId}`;
|
||||
this._select.appendChild(o);
|
||||
o.value = p.id;
|
||||
o.textContent = p.name ? `${p.id} (${p.name})` : p.id;
|
||||
this._providerSelect.appendChild(o);
|
||||
}
|
||||
// No settings-based default — pick the first available option.
|
||||
this._select.value = this._select.options[0].value;
|
||||
this._providerSelect.value = this._providerModels[0].id;
|
||||
this._providerSelect.addEventListener('change', () => {
|
||||
this._rebuildModelSelect(this._providerSelect!.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (this._providerModels.length > 0) {
|
||||
this._modelSelect = document.createElement('select');
|
||||
this._modelSelect.className = 'opencode-model-select';
|
||||
this._rebuildModelSelect(this._providerSelect?.value);
|
||||
}
|
||||
|
||||
this._textarea = document.createElement('textarea');
|
||||
@@ -59,7 +114,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
const [providerId, modelId] = this._readSelection();
|
||||
const providerId = this._providerSelect?.value;
|
||||
const modelId = this._modelSelect?.value || undefined;
|
||||
options.onSubmit(text, providerId, modelId);
|
||||
});
|
||||
this._cancelBtn = document.createElement('button');
|
||||
@@ -74,54 +130,70 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
actions.appendChild(this._sendBtn);
|
||||
actions.appendChild(this._cancelBtn);
|
||||
|
||||
if (this._select) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'opencode-model-label';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = '模型:';
|
||||
label.appendChild(span);
|
||||
label.appendChild(this._select);
|
||||
this.node.appendChild(label);
|
||||
if (this._providerSelect || this._modelSelect) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'opencode-prompt-providers';
|
||||
if (this._providerSelect) {
|
||||
const pLabel = document.createElement('label');
|
||||
const pSpan = document.createElement('span');
|
||||
pSpan.textContent = 'Provider:';
|
||||
pLabel.appendChild(pSpan);
|
||||
pLabel.appendChild(this._providerSelect);
|
||||
row.appendChild(pLabel);
|
||||
}
|
||||
if (this._modelSelect) {
|
||||
const mLabel = document.createElement('label');
|
||||
const mSpan = document.createElement('span');
|
||||
mSpan.textContent = 'Model:';
|
||||
mLabel.appendChild(mSpan);
|
||||
mLabel.appendChild(this._modelSelect);
|
||||
row.appendChild(mLabel);
|
||||
}
|
||||
this.node.appendChild(row);
|
||||
}
|
||||
this.node.appendChild(this._textarea);
|
||||
this.node.appendChild(actions);
|
||||
}
|
||||
|
||||
private _rebuildModelSelect(providerId: string | undefined): void {
|
||||
if (!this._modelSelect) {
|
||||
return;
|
||||
}
|
||||
while (this._modelSelect.firstChild) {
|
||||
this._modelSelect.removeChild(this._modelSelect.firstChild);
|
||||
}
|
||||
if (!providerId) {
|
||||
this._modelSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
const provider = this._providerModels.find(p => p.id === providerId);
|
||||
if (!provider) {
|
||||
this._modelSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
const keys = modelKeys(provider.models);
|
||||
if (keys.length === 0) {
|
||||
this._modelSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
for (const k of keys) {
|
||||
const o = document.createElement('option');
|
||||
o.value = k;
|
||||
o.textContent = k;
|
||||
this._modelSelect.appendChild(o);
|
||||
}
|
||||
this._modelSelect.disabled = false;
|
||||
this._modelSelect.value =
|
||||
pickDefaultModelId(providerId, provider.models, this._defaultMap) ?? keys[0];
|
||||
}
|
||||
|
||||
setDisabled(disabled: boolean): void {
|
||||
this._sendBtn.disabled = disabled;
|
||||
if (this._select) {
|
||||
this._select.disabled = disabled;
|
||||
if (this._providerSelect) {
|
||||
this._providerSelect.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
private _readSelection(): [string | undefined, string | undefined] {
|
||||
if (!this._select) {
|
||||
return [undefined, undefined];
|
||||
if (this._modelSelect) {
|
||||
this._modelSelect.disabled = disabled;
|
||||
}
|
||||
const parts = this._select.value.split('|', 2);
|
||||
if (parts.length !== 2) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
return [parts[0], parts[1]];
|
||||
}
|
||||
}
|
||||
|
||||
function flattenProviders(
|
||||
providers: OpenCodeProvidersResponse | null
|
||||
): { providerId: string; modelId: string }[] {
|
||||
if (!providers || !providers.providers) {
|
||||
return [];
|
||||
}
|
||||
const out: { providerId: string; modelId: string }[] = [];
|
||||
for (const p of providers.providers) {
|
||||
// Per the OpenCode server docs, Provider.models is a Record keyed by
|
||||
// modelID ({ [modelID: string]: Model }), NOT an array.
|
||||
if (!p.models || typeof p.models !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const modelId of Object.keys(p.models)) {
|
||||
out.push({ providerId: p.id, modelId });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
||||
'[opencode_bridge] Available OpenCode providers:'
|
||||
];
|
||||
for (const p of data.providers) {
|
||||
const models = p.models.map(m => m.id).join(', ');
|
||||
const models = Object.values(p.models).map(m => m.id).join(', ');
|
||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
+24
-4
@@ -42,17 +42,37 @@
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-model-label {
|
||||
.opencode-inline-prompt .opencode-prompt-providers {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-prompt-providers label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
color: var(--jp-ui-font-color1, #333);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-provider-select,
|
||||
.opencode-inline-prompt .opencode-model-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-width: 120px;
|
||||
padding: 2px 4px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--jp-border-color2, #ccc);
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-provider-select:disabled,
|
||||
.opencode-inline-prompt .opencode-model-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt textarea {
|
||||
|
||||
Reference in New Issue
Block a user