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:
tao.chen
2026-07-23 18:13:14 +08:00
co-authored by Claude Fable 5
parent e7579d3779
commit 04f90ab5a0
4 changed files with 261 additions and 72 deletions
+129 -57
View File
@@ -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;
}