From 04f90ab5a088e79568b8f69ba56c3a839224fb68 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:13:14 +0800 Subject: [PATCH] feat: dual select (provider + model) with default[provider] model default The inline prompt now renders two 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; -} diff --git a/src/index.ts b/src/index.ts index d58fc80..3050c04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,7 +62,7 @@ const plugin: JupyterFrontEndPlugin = { '[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 diff --git a/style/base.css b/style/base.css index 9bb2c8c..bf58278 100644 --- a/style/base.css +++ b/style/base.css @@ -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 {