From 74953d0ab8bf902361e02036981e0e74138c2270 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:11:20 +0800 Subject: [PATCH] feat: fully dynamic model picker in inline prompt --- src/__tests__/opencode_cell_actions.spec.ts | 79 +++++++++++++++++---- src/components/opencode_cell_actions.ts | 50 +++++++------ src/components/opencode_inline_prompt.ts | 75 +++++++++++++++++-- 3 files changed, 164 insertions(+), 40 deletions(-) diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 36cffea..0ea462d 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -1,12 +1,13 @@ /** * Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt - * (inline input panel inside the cell). + * (inline input panel inside the cell). v3-final: no settings — the model + * picker is fully dynamic; default selection is the first provider/model. * * Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the * test boundary so the real ESM packages are not loaded. */ jest.mock('@jupyterlab/cells', () => ({ - CodeCell: class CodeCell { public node = document.createElement('div'); } + CodeCell: class CodeCell {} })); jest.mock('@jupyterlab/apputils', () => ({ @@ -32,21 +33,25 @@ jest.mock('@lumino/widgets', () => { } public dispose(): void { this._isDisposed = true; - if (this.node.parentElement) { - this.node.parentElement.removeChild(this.node); + if (this.node.parentNode) { + this.node.parentNode.removeChild(this.node); } } - public onAfterAttach(msg: any): void { - // no-op for tests - } public static attach(widget: Widget, host: HTMLElement): void { host.appendChild(widget.node); } + public onAfterAttach(_msg: any): void { + // no-op + } } return { Widget }; }); -import { OpenCodeCellActions } from '../components/opencode_cell_actions'; +import { + OpenCodeCellActions, + setOpenCodeProviders, + setOpenCodeServerSettings +} from '../components/opencode_cell_actions'; import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt'; import { CodeCell } from '@jupyterlab/cells'; @@ -81,6 +86,7 @@ function makeFakeCell( }; const cell = new (CodeCell as unknown as new () => CodeCell)(); (cell as any).model = model; + (cell as any).node = document.createElement('div'); const notebook: any = { widgets: [] as CodeCell[] }; const panel: any = { @@ -112,7 +118,11 @@ function makeFakeCell( } describe('OpenCodeCellActions', () => { - it('renders a single AI button (not 3 buttons)', () => { + it('allows server settings to be injected', () => { + setOpenCodeServerSettings({} as any); + }); + + it('renders a single AI button', () => { const cell = makeFakeCell('x = 1'); const actions = new OpenCodeCellActions(cell); const btns = actions.node.querySelectorAll('button'); @@ -138,7 +148,6 @@ describe('OpenCodeCellActions', () => { it('clicking the button attaches an OpenCodeInlinePrompt to the cell', () => { const cell = makeFakeCell('x = 1'); const actions = new OpenCodeCellActions(cell); - // Attach actions to cell so it can resolve parent chain Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); @@ -160,8 +169,7 @@ describe('OpenCodeCellActions', () => { const btn = actions.node.querySelector('button') as HTMLButtonElement; btn.click(); btn.click(); - const prompt = cell.node.querySelector('.opencode-inline-prompt'); - expect(prompt).toBeNull(); + expect(cell.node.querySelector('.opencode-inline-prompt')).toBeNull(); }); it('disconnects model signals on dispose', () => { @@ -172,15 +180,58 @@ describe('OpenCodeCellActions', () => { expect(model.contentChanged.disconnect).toHaveBeenCalled(); expect(model.stateChanged.disconnect).toHaveBeenCalled(); }); + + it('renders a model selector with the first option selected when providers are cached', () => { + setOpenCodeProviders({ + providers: [ + { id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] }, + { id: 'openai', models: [{ id: '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 select = 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'); + setOpenCodeProviders(null); + }); + + it('does not render the selector when providers are not cached', () => { + setOpenCodeProviders(null); + 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(); + + expect( + cell.node.querySelector('.opencode-inline-prompt .opencode-model-select') + ).toBeNull(); + }); }); describe('OpenCodeInlinePrompt', () => { - it('renders a textarea, a send and a cancel button', () => { + it('renders a textarea, a send and a cancel button (no selector when providers null)', () => { const cell = makeFakeCell('x = 1'); const prompt = new OpenCodeInlinePrompt(cell, { onSubmit: jest.fn(), onCancel: jest.fn(), - disabled: false + disabled: false, + providers: null }); expect(prompt.node.querySelector('textarea')).not.toBeNull(); expect(prompt.node.querySelectorAll('button').length).toBe(2); diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts index 2d7acf0..2c0f2af 100644 --- a/src/components/opencode_cell_actions.ts +++ b/src/components/opencode_cell_actions.ts @@ -5,9 +5,10 @@ * * Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...). * - * The frontend does NO semantic processing: it gathers the cell's source / - * outputs / error, attaches the user's freeform instruction, and POSTs to - * the server. The server (and the LLM it forwards to) decides what to do. + * v3-final: the frontend does NO semantic processing. It gathers the cell's + * source / outputs / error, attaches the user's freeform instruction, and + * POSTs to the server. The chosen provider/model comes from the inline + * picker (no settings-based default). */ import type { CodeCell } from '@jupyterlab/cells'; import { Notification } from '@jupyterlab/apputils'; @@ -19,23 +20,27 @@ import { callOpenCodeEdit } from '../api/opencode_client'; import { extractCellContext } from '../context/cell_context'; import type { CellContext, + OpenCodeProvidersResponse, OpenCodeRequest, - OpenCodeResponse, - OpenCodeSettings + OpenCodeResponse } from '../types'; import { OpenCodeInlinePrompt } from './opencode_inline_prompt'; -// Module-level runtime injection (set by index.ts after settings load). -let _settings: OpenCodeSettings | null = null; +// Module-level runtime injection (set by index.ts after activation). let _serverSettings: ServerConnection.ISettings | null = null; +let _providers: OpenCodeProvidersResponse | null = null; -export function setOpenCodeRuntime(args: { - settings: OpenCodeSettings; - serverSettings: ServerConnection.ISettings; -}): void { - _settings = args.settings; - _serverSettings = args.serverSettings; +export function setOpenCodeServerSettings( + serverSettings: ServerConnection.ISettings +): void { + _serverSettings = serverSettings; +} + +export function setOpenCodeProviders( + p: OpenCodeProvidersResponse | null +): void { + _providers = p; } type Status = 'idle' | 'loading'; @@ -108,8 +113,9 @@ export class OpenCodeCellActions extends Widget { } const prompt = new OpenCodeInlinePrompt(this._cell, { disabled: !this._context || this._status === 'loading', - onSubmit: (text: string) => { - void this._onSubmit(text); + providers: _providers, + onSubmit: (text: string, providerId?: string, modelId?: string) => { + void this._onSubmit(text, providerId, modelId); }, onCancel: () => { this._hidePrompt(); @@ -126,20 +132,24 @@ export class OpenCodeCellActions extends Widget { } } - private async _onSubmit(text: string): Promise { + private async _onSubmit( + text: string, + providerId?: string, + modelId?: string + ): Promise { if (!this._context) { return; } - if (!_settings || !_serverSettings) { - Notification.error('OpenCode 运行时未初始化,请检查 settings'); + if (!_serverSettings) { + Notification.error('OpenCode 运行时未初始化'); return; } const request: OpenCodeRequest = { prompt: text, context: this._context, - providerId: _settings.opencodeProvider || undefined, - modelId: _settings.opencodeModel || undefined + providerId, + modelId }; this._status = 'loading'; diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts index 0408ae0..db06ee8 100644 --- a/src/components/opencode_inline_prompt.ts +++ b/src/components/opencode_inline_prompt.ts @@ -1,22 +1,30 @@ /** * Inline prompt widget attached to a cell's DOM when the AI button is clicked. - * Renders a textarea + send/cancel buttons. The owning cell is passed so - * we can resolve the cell context at submit time. submit/cancel are - * callbacks owned by OpenCodeCellActions. + * 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. + * + * 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. */ import { CodeCell } from '@jupyterlab/cells'; import { Widget } from '@lumino/widgets'; +import type { OpenCodeProvidersResponse } from '../types'; + export interface IOpenCodeInlinePromptOptions { - onSubmit: (text: string) => void; + onSubmit: (text: string, providerId?: string, modelId?: string) => void; onCancel: () => void; disabled: boolean; + providers: OpenCodeProvidersResponse | null; } export class OpenCodeInlinePrompt extends Widget { private _textarea: HTMLTextAreaElement; private _sendBtn: HTMLButtonElement; private _cancelBtn: HTMLButtonElement; + private _select: HTMLSelectElement | null = null; constructor( _cell: CodeCell, @@ -24,6 +32,21 @@ export class OpenCodeInlinePrompt extends Widget { ) { super(); 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) { + const o = document.createElement('option'); + o.value = `${opt.providerId}|${opt.modelId}`; + o.textContent = `${opt.providerId} / ${opt.modelId}`; + this._select.appendChild(o); + } + // No settings-based default — pick the first available option. + this._select.value = this._select.options[0].value; + } + this._textarea = document.createElement('textarea'); this._textarea.placeholder = '向 AI 描述你想要的修改…'; this._textarea.rows = 3; @@ -33,9 +56,11 @@ export class OpenCodeInlinePrompt extends Widget { this._sendBtn.disabled = options.disabled; this._sendBtn.addEventListener('click', () => { const text = this._textarea.value; - if (text.trim()) { - options.onSubmit(text); + if (!text.trim()) { + return; } + const [providerId, modelId] = this._readSelection(); + options.onSubmit(text, providerId, modelId); }); this._cancelBtn = document.createElement('button'); this._cancelBtn.className = 'opencode-btn-cancel'; @@ -49,11 +74,49 @@ 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); + } this.node.appendChild(this._textarea); this.node.appendChild(actions); } setDisabled(disabled: boolean): void { this._sendBtn.disabled = disabled; + if (this._select) { + this._select.disabled = disabled; + } + } + + private _readSelection(): [string | undefined, string | undefined] { + if (!this._select) { + return [undefined, undefined]; + } + 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) { + for (const m of p.models) { + out.push({ providerId: p.id, modelId: m.id }); + } + } + return out; +}