fix(web+cli): Cursor model picker empty on bare ACP ids + nested variant drill-down (#947)

* feat(web): in-place cursor variant drill-down (closes #48)

Rebased onto upstream/main: iOS-style nested picker keeps overlay open on
multi-variant base pick, applies default variant immediately, dismisses on
variant selection; preserves upstream Pi model panels and Codex Fast mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web+cli): accept bare Cursor ACP model ids in picker catalog

Current Cursor ACP returns bare bases (composer-2.5, …) with empty
cliModelSkus. The bracket-only wire gate emptied the catalog so the
picker showed only Default. Treat bare non-default ACP ids as catalog
rows, keep CLI effort/speed SKUs as variants, and widen SKU enrichment
the same way. Closes #1129.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): ignore stale selectedModelVariant during Cursor base drill-down

Only highlight a session variant when it is still among the visible
rows, so a multi-variant base switch uses the new default until parent
state catches up (Codex Minor on #947).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli+shared): do not attach CLI variant SKUs to bare ACP catalogs

Bare ACP bases cannot express effort/speed (apply is model+fast on
parameterized wires). Drop suffixed SKUs unless a base has bracket
wires, and refuse matchCliSkuToAcpWireId collapse onto bare-only rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): serialize Cursor model applies across base/variant picks

Drill-down default apply and a quick variant click could race setModel
RPCs; last-finisher wins. Queue Cursor applies in SessionChat so the
explicit variant cannot be overwritten by a late default.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): align cursor picker auto-row label with upstream Auto

Rebase onto main picked up Default→Auto rename; keep #1129 coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
This commit is contained in:
HeavyGee
2026-08-04 10:59:06 +08:00
committed by GitHub
co-authored by Cursor Debian
parent 00e8fc3a47
commit 3c83fe58c9
21 changed files with 868 additions and 95 deletions
+42
View File
@@ -13,6 +13,7 @@ import {
cursorVariantDisambiguationSuffix,
cursorVariantLabel,
cursorVaryingWireParamKeys,
filterCursorModelOptionsForCompactView,
formatCursorModelPickerLabel,
parseCursorWireParams,
resolveCursorBaseKey,
@@ -156,3 +157,44 @@ describe('picker labels and modes', () => {
expect(formatCursorModelPickerLabel('composer-2.5[fast=true]', 'ignored')).toBe('composer-2.5 · fast=true')
})
})
describe('filterCursorModelOptionsForCompactView (iOS-style nested picker)', () => {
const options: { value: string | null; label: string }[] = [
{ value: 'auto', label: 'Default' },
{ value: 'claude-fable-5', label: 'claude-fable-5' },
{ value: 'claude-opus-4-7', label: 'claude-opus-4-7' },
{ value: 'composer-2.5', label: 'composer-2.5' },
{ value: 'gpt-5.5', label: 'gpt-5.5' }
]
it('passes the full list through when nothing or Default is selected', () => {
expect(filterCursorModelOptionsForCompactView(options, undefined)).toEqual(options)
expect(filterCursorModelOptionsForCompactView(options, null)).toEqual(options)
expect(filterCursorModelOptionsForCompactView(options, 'auto')).toEqual(options)
})
it('collapses to Default + the selected base when a non-Default base is picked', () => {
expect(filterCursorModelOptionsForCompactView(options, 'claude-fable-5')).toEqual([
{ value: 'auto', label: 'Default' },
{ value: 'claude-fable-5', label: 'claude-fable-5' }
])
})
it('treats `null`-valued Default rows as the Default passthrough', () => {
const withNullDefault: { value: string | null; label: string }[] = [
{ value: null, label: 'Default' },
{ value: 'composer-2.5', label: 'composer-2.5' },
{ value: 'gpt-5.5', label: 'gpt-5.5' }
]
expect(filterCursorModelOptionsForCompactView(withNullDefault, 'gpt-5.5')).toEqual([
{ value: null, label: 'Default' },
{ value: 'gpt-5.5', label: 'gpt-5.5' }
])
})
it('returns just Default when the selected base is not in the option set (catalog drift)', () => {
expect(filterCursorModelOptionsForCompactView(options, 'phantom-model-9')).toEqual([
{ value: 'auto', label: 'Default' }
])
})
})
+82 -5
View File
@@ -1,4 +1,9 @@
import { cursorCliSkuBaseId } from '@hapi/protocol'
import {
cursorCliSkuBaseId,
findBestCliSkuForAcpWire,
isCursorAcpCatalogModelId,
isCursorAcpWireModelId as isSharedCursorAcpWireModelId
} from '@hapi/protocol'
import type { CursorModelSummary } from '@/types/api'
export type CursorModelOption = { value: string | null; label: string }
@@ -130,6 +135,57 @@ export function buildCursorEffortPickerOptions(
}))
}
/**
* Default variant for a base: the first ACP wire row in catalog order, mapped to
* the best CLI sku when sku rows replace raw ACP wires in the picker.
*/
export function resolveDefaultCursorVariantWire(
baseKey: string,
catalog: CursorModelCatalog
): string | null {
const pickerVariants = resolveCursorVariantOptions(baseKey, catalog)
if (pickerVariants.length === 0) {
return null
}
if (pickerVariants.length === 1) {
return pickerVariants[0].wireId
}
const catalogVariants = catalog.variantsByBase.get(baseKey) ?? []
const defaultAcp = catalogVariants.find((entry) => isCursorAcpWireModelId(entry.wireId))
?? catalogVariants[0]
if (pickerVariants.some((entry) => entry.wireId === defaultAcp.wireId)) {
return defaultAcp.wireId
}
const bestSku = findBestCliSkuForAcpWire(
defaultAcp.wireId,
pickerVariants.map((entry) => entry.wireId)
)
return bestSku ?? pickerVariants[0].wireId
}
/** Variant rows with the base default first (for drill-down picker step). */
export function buildCursorEffortPickerOptionsWithDefaultFirst(
baseKey: string,
catalog: CursorModelCatalog
): Array<{ value: string; label: string }> {
const variants = resolveCursorVariantOptions(baseKey, catalog)
const options = buildCursorEffortPickerOptions(variants)
const defaultWire = resolveDefaultCursorVariantWire(baseKey, catalog)
if (!defaultWire) {
return options
}
const defaultOption = options.find((option) => option.value === defaultWire)
if (!defaultOption) {
return options
}
return [
defaultOption,
...options.filter((option) => option.value !== defaultWire)
]
}
/** Raw suffix for compatibility with older callers/tests. */
export function cursorVariantDisambiguationSuffix(modelId: string): string {
return cursorVariantLabel(modelId)
@@ -180,7 +236,7 @@ export function buildCursorModelCatalog(
}
const normalizedCurrent = normalizeCurrentModel(options?.currentModel)
if (normalizedCurrent && isCursorAcpWireModelId(normalizedCurrent) && !wireToBase.has(normalizedCurrent)) {
if (normalizedCurrent && isCursorAcpCatalogModelId(normalizedCurrent) && !wireToBase.has(normalizedCurrent)) {
addWire(normalizedCurrent)
}
@@ -280,10 +336,9 @@ export function cursorBaseHasMultipleVariants(
return (catalog.variantsByBase.get(baseKey)?.length ?? 0) > 1
}
/** ACP wire ids use bracket params; CLI probe slugs (e.g. gpt-5.5-high-fast) are not picker rows. */
/** ACP parameterized wire ids use bracket params; re-export shared predicate. */
export function isCursorAcpWireModelId(modelId: string): boolean {
const trimmed = modelId.trim()
return trimmed === 'default[]' || trimmed.includes('[')
return isSharedCursorAcpWireModelId(modelId)
}
/** Dual pickers only when at least one base has multiple ACP wire ids. */
@@ -337,3 +392,25 @@ export function formatCursorModelPickerLabel(modelId: string, _name?: string | n
const variant = cursorModelVariantId(modelId)
return variant ? `${base} · ${variant}` : base
}
/**
* iOS-style "configure my model" view: when a non-Default base is selected, hide
* every other base row so the user sees Default + selected + (optional Variant
* section below). Caller appends a "Change model…" toggle to re-expand.
*
* Default-row passthrough recognizes both `'auto'` (the in-picker token) and
* `null` (the underlying base option value) so callers don't need to normalize.
*/
export function filterCursorModelOptionsForCompactView(
modelOptions: readonly { value: string | null; label: string }[],
selectedModelBase: string | null | undefined
): readonly { value: string | null; label: string }[] {
if (!selectedModelBase || selectedModelBase === 'auto') {
return modelOptions
}
return modelOptions.filter(
(option) => option.value === null
|| option.value === 'auto'
|| option.value === selectedModelBase
)
}
+93 -2
View File
@@ -30,7 +30,7 @@ describe('mergeCursorModelSummaries', () => {
])
})
it('drops CLI probe slugs without bracket wire params', () => {
it('drops CLI effort/speed SKU slugs but keeps bare ACP bases', () => {
const merged = mergeCursorModelSummaries(
[{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }],
[
@@ -39,7 +39,8 @@ describe('mergeCursorModelSummaries', () => {
]
)
expect(merged.map((entry) => entry.modelId)).toEqual([
'gpt-5.5[context=272k,reasoning=medium,fast=false]'
'gpt-5.5[context=272k,reasoning=medium,fast=false]',
'composer-2.5'
])
})
})
@@ -109,6 +110,96 @@ describe('buildCursorPickerState', () => {
})
})
describe('live bare ACP catalog (#1129)', () => {
/** Live Cursor ACP shape (2026-07-22): bare bases, no brackets, empty cliModelSkus. */
const LIVE_BARE_ACP_IDS = [
'composer-2',
'composer-2.5',
'gpt-5.5',
'gpt-5.4',
'gpt-5.3-codex',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-sonnet-4-6',
'claude-sonnet-4-5',
'claude-haiku-4-5',
'gemini-3.1-pro',
'gemini-3-flash',
'grok-4-20',
'kimi-k2.5',
'o3',
'o4-mini',
'gpt-4.1',
'gpt-4o',
'claude-4-sonnet',
'claude-4-opus',
'claude-3.7-sonnet',
'claude-3.5-sonnet',
'claude-3.5-haiku',
'gemini-2.5-pro',
'gemini-2.5-flash',
'deepseek-r1',
'deepseek-v3.1',
'cheetah',
'auto',
'default',
// Accidental CLI SKU leakage into availableModels must not become a top-level row.
'composer-2.5-fast'
] as const
it('builds a non-empty flat picker from live-shaped bare ACP ids', () => {
expect(LIVE_BARE_ACP_IDS).toHaveLength(31)
const sessionModels = LIVE_BARE_ACP_IDS.map((modelId) => ({ modelId }))
const catalog = buildCursorCatalogFromSources({
sessionModels,
machineModels: [],
cliModelSkus: [],
currentWireId: 'default',
defaultValue: null
})
const picker = buildCursorPickerState({
catalog,
currentWireId: 'default',
defaultValue: null
})
expect(catalog.variantsByBase.size).toBeGreaterThan(0)
expect(picker.modelOptions.length).toBeGreaterThan(1)
expect(picker.modelOptions.some((row) => row.value === 'composer-2.5')).toBe(true)
expect(picker.modelOptions.some((row) => row.value === 'claude-opus-4-8')).toBe(true)
// CLI effort/speed SKUs must not become top-level bases.
expect(picker.modelOptions.some((row) => row.value === 'composer-2.5-fast')).toBe(false)
// Default tokens stay out of the catalog rows (Default row is synthetic auto).
expect(picker.modelOptions.some((row) => row.value === 'default')).toBe(false)
expect(picker.modelOptions[0]).toEqual({ value: 'auto', label: 'Auto' })
})
it('attaches CLI SKUs under bare ACP bases for dual/nested variant UX', () => {
const catalog = buildCursorCatalogFromSources({
sessionModels: [
{ modelId: 'composer-2.5' },
{ modelId: 'gpt-5.5' }
],
cliModelSkus: [
{ modelId: 'composer-2.5', name: 'Composer 2.5' },
{ modelId: 'composer-2.5-fast', name: 'Composer 2.5 Fast' },
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' },
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' }
],
defaultValue: null
})
const picker = buildCursorPickerState({
catalog,
currentWireId: 'composer-2.5',
defaultValue: null
})
expect(picker.mode).toBe('dual')
expect(picker.modelOptions.some((row) => row.value === 'composer-2.5')).toBe(true)
expect(picker.showEffortPicker).toBe(true)
expect(picker.effortOptions.some((row) => row.value === 'composer-2.5-fast')).toBe(true)
})
})
describe('resolveWireIdForBaseChange', () => {
it('does not guess when switching to a base with multiple variants', () => {
const catalog = buildCursorCatalogFromSources({
+9 -6
View File
@@ -1,3 +1,4 @@
import { isCursorAcpCatalogModelId } from '@hapi/protocol'
import type { CursorModelSummary } from '@/types/api'
import {
appendCliSkusToCatalog,
@@ -5,7 +6,6 @@ import {
buildCursorModelCatalog,
buildFlatCursorModelPickerOptions,
cursorModelDedupeKey,
isCursorAcpWireModelId,
resolveCursorBaseKey,
resolveCursorVariantOptions,
shouldUseCursorDualPickers,
@@ -46,11 +46,14 @@ export type CursorPickerState = {
showEffortPicker: boolean
}
/** Only ACP wire ids (and default[]); never CLI probe slugs without bracket params. */
/**
* ACP catalog rows for the picker: parameterized wires and bare non-default bases.
* Never CLI effort/speed SKU slugs (those attach as variants under a base).
*/
export function pickCursorModelsForPicker(
availableModels: readonly CursorModelSummary[]
): CursorModelSummary[] {
return availableModels.filter((model) => isCursorAcpWireModelId(model.modelId))
return availableModels.filter((model) => isCursorAcpCatalogModelId(model.modelId))
}
/**
@@ -66,7 +69,7 @@ export function mergeCursorModelSummaries(
const add = (model: CursorModelSummary) => {
const modelId = model.modelId.trim()
if (!modelId || !isCursorAcpWireModelId(modelId)) {
if (!modelId || !isCursorAcpCatalogModelId(modelId)) {
return
}
if (!merged.has(modelId)) {
@@ -82,7 +85,7 @@ export function mergeCursorModelSummaries(
}
const trimmedCurrent = currentWireId?.trim()
if (trimmedCurrent && isCursorAcpWireModelId(trimmedCurrent) && !merged.has(trimmedCurrent)) {
if (trimmedCurrent && isCursorAcpCatalogModelId(trimmedCurrent) && !merged.has(trimmedCurrent)) {
merged.set(trimmedCurrent, { modelId: trimmedCurrent })
}
@@ -105,7 +108,7 @@ export function buildCursorCatalogFromSources(args: {
args.machineModels ?? [],
wireHint
)
const injectCurrent = wireHint && isCursorAcpWireModelId(wireHint) ? wireHint : null
const injectCurrent = wireHint && isCursorAcpCatalogModelId(wireHint) ? wireHint : null
const catalog = buildCursorModelCatalog(pickCursorModelsForPicker(merged), {
currentModel: injectCurrent,
defaultValue: args.defaultValue
+25 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { appendCliSkusToCatalog, buildCursorEffortPickerOptions, buildCursorModelCatalog, resolveCursorVariantOptions } from '@/lib/cursorModelOptions'
import {
appendCliSkusToCatalog,
buildCursorEffortPickerOptions,
buildCursorEffortPickerOptionsWithDefaultFirst,
buildCursorModelCatalog,
resolveCursorVariantOptions,
resolveDefaultCursorVariantWire
} from '@/lib/cursorModelOptions'
describe('resolveCursorVariantOptions with CLI skus', () => {
it('omits raw ACP wire row when CLI skus exist for the same base', () => {
@@ -19,6 +26,23 @@ describe('resolveCursorVariantOptions with CLI skus', () => {
expect(options.some((row) => row.label.includes('context=272k'))).toBe(false)
})
it('puts the default variant first for drill-down picker rows', () => {
const catalog = appendCliSkusToCatalog(
buildCursorModelCatalog([
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }
]),
[
{ modelId: 'gpt-5.5-medium', name: 'GPT-5.5 1M' },
{ modelId: 'gpt-5.5-high-fast', name: 'GPT-5.5 High Fast' }
]
)
expect(resolveDefaultCursorVariantWire('gpt-5.5', catalog)).toBe('gpt-5.5-medium')
expect(buildCursorEffortPickerOptionsWithDefaultFirst('gpt-5.5', catalog).map((row) => row.value)).toEqual([
'gpt-5.5-medium',
'gpt-5.5-high-fast'
])
})
it('keeps ACP wire rows when no CLI skus are attached', () => {
const catalog = buildCursorModelCatalog([
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
+2
View File
@@ -955,6 +955,8 @@ export default {
'misc.fastModeStandard': 'Standard',
'misc.fastModeFast': 'Fast',
'misc.variant': 'Variant',
'misc.changeModel': 'Change model…',
'misc.backToModelList': '← Models',
'misc.loading': 'Loading…',
'misc.newMessage': '{n} new message{s}',
'misc.loadingMessages': 'Loading messages…',
+2
View File
@@ -954,6 +954,8 @@ export default {
'misc.fastModeStandard': '标准',
'misc.fastModeFast': '快速',
'misc.variant': '变体',
'misc.changeModel': '更换模型…',
'misc.backToModelList': '← 模型',
'misc.loading': '加载中…',
'misc.newMessage': '{n} 条新消息',
'misc.loadingMessages': '加载消息中…',
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { createSerialAsyncQueue } from './serialAsyncQueue'
describe('createSerialAsyncQueue', () => {
it('runs enqueued work in order even when the second starts while the first is pending', async () => {
const enqueue = createSerialAsyncQueue()
const order: number[] = []
let releaseFirst!: () => void
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const first = enqueue(async () => {
await firstGate
order.push(1)
})
const second = enqueue(async () => {
order.push(2)
})
expect(order).toEqual([])
releaseFirst()
await Promise.all([first, second])
expect(order).toEqual([1, 2])
})
it('continues after a rejected run', async () => {
const enqueue = createSerialAsyncQueue()
const order: number[] = []
await enqueue(async () => {
order.push(1)
throw new Error('boom')
}).catch(() => undefined)
await enqueue(async () => {
order.push(2)
})
expect(order).toEqual([1, 2])
})
})
+12
View File
@@ -0,0 +1,12 @@
/**
* FIFO chain for async work that must not overlap (e.g. Cursor setModel RPCs).
* Failures in one run do not break later enqueues.
*/
export function createSerialAsyncQueue(): (run: () => Promise<void>) => Promise<void> {
let chain: Promise<void> = Promise.resolve()
return (run) => {
const pending = chain.then(run, run)
chain = pending.then(() => undefined, () => undefined)
return pending
}
}
+51 -3
View File
@@ -23,7 +23,7 @@ describe('resolveSessionCursorModelChange', () => {
sessionCurrentModelId: 'composer-2.5[fast=true]'
})
it('updates selected base without applying when the base has multiple variants', () => {
it('applies the default variant and keeps base selected when the base has multiple variants', () => {
const plan = resolveSessionCursorModelChange({
picker,
sessionModel: 'composer-2.5[fast=true]',
@@ -33,9 +33,9 @@ describe('resolveSessionCursorModelChange', () => {
})
expect(plan).toEqual({
ok: true,
wireId: null,
wireId: 'composer-2.5[fast=true]',
nextSelectedBase: 'composer-2.5',
shouldApply: false
shouldApply: true
})
})
@@ -106,6 +106,54 @@ describe('resolveSessionCursorModelChange', () => {
})
expect(resolveSessionCursorBaseSelectValue(defaultPicker, 'auto')).toBe('auto')
})
// Cursor ACP without parameterizedModelPicker returns one wire per base = flat picker.
// The picker row for 'Default' is value='auto', so the resolver must yield 'auto' when
// session.model is null, otherwise HappyComposer's `selectedModelBase === option.value`
// check fails for every row and the dropdown looks empty.
it('highlights Default in flat-mode picker when session is on ACP default[]', () => {
const flatPicker = buildSessionCursorPickerState({
sessionModels: [
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }
],
machineModels: [],
sessionModel: null,
sessionCurrentModelId: null
})
expect(flatPicker.mode).toBe('flat')
expect(resolveSessionCursorBaseSelectValue(flatPicker, 'auto')).toBe('auto')
})
it('highlights the active wire id in flat-mode picker when session has an explicit model', () => {
const flatPicker = buildSessionCursorPickerState({
sessionModels: [
{ modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
{ modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' }
],
machineModels: [],
sessionModel: 'gpt-5.5[context=272k,reasoning=medium,fast=false]',
sessionCurrentModelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]'
})
expect(flatPicker.mode).toBe('flat')
expect(resolveSessionCursorBaseSelectValue(flatPicker, 'auto'))
.toBe('gpt-5.5[context=272k,reasoning=medium,fast=false]')
})
it('highlights bare ACP bases in flat mode (#1129)', () => {
const flatPicker = buildSessionCursorPickerState({
sessionModels: [
{ modelId: 'composer-2.5', name: 'composer-2.5' },
{ modelId: 'gpt-5.5', name: 'gpt-5.5' }
],
machineModels: [],
sessionModel: 'composer-2.5',
sessionCurrentModelId: 'composer-2.5'
})
expect(flatPicker.mode).toBe('flat')
expect(flatPicker.modelOptions.some((row) => row.value === 'composer-2.5')).toBe(true)
expect(resolveSessionCursorBaseSelectValue(flatPicker, 'auto')).toBe('composer-2.5')
})
})
describe('CLI sku variants in session picker', () => {
+11
View File
@@ -1,5 +1,6 @@
import { findBestCliSkuForAcpWire, matchCliSkuToAcpWireId } from '@hapi/protocol'
import type { CursorModelCatalog } from '@/lib/cursorModelOptions'
import { resolveCursorVariantOptions, resolveDefaultCursorVariantWire } from '@/lib/cursorModelOptions'
import type { CursorModelSummary } from '@/types/api'
import {
buildCursorCatalogFromSources,
@@ -50,6 +51,16 @@ export function resolveSessionCursorModelChange(args: {
const base = resolveCursorBaseFromWire(value, picker.catalog)
return { ok: true, wireId: value, nextSelectedBase: base, shouldApply: true }
}
const variants = resolveCursorVariantOptions(value, picker.catalog)
if (variants.length > 1) {
const defaultWire = resolveDefaultCursorVariantWire(value, picker.catalog)
return {
ok: true,
wireId: defaultWire,
nextSelectedBase: value,
shouldApply: defaultWire !== null
}
}
const wireId = resolveWireIdForBaseChange(value, picker.catalog, sessionModel)
return {
ok: true,