Files
hapi/web/src/lib/cursorPickerState.ts
T
3c83fe58c9 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>
2026-08-04 10:59:06 +08:00

201 lines
6.3 KiB
TypeScript

import { isCursorAcpCatalogModelId } from '@hapi/protocol'
import type { CursorModelSummary } from '@/types/api'
import {
appendCliSkusToCatalog,
buildCursorEffortPickerOptions,
buildCursorModelCatalog,
buildFlatCursorModelPickerOptions,
cursorModelDedupeKey,
resolveCursorBaseKey,
resolveCursorVariantOptions,
shouldUseCursorDualPickers,
type CursorModelCatalog,
type CursorModelOption
} from '@/lib/cursorModelOptions'
export function mergeCursorCliModelSkus(
...sources: readonly (readonly CursorModelSummary[])[]
): CursorModelSummary[] {
const sorted = [...sources].sort((a, b) => b.length - a.length);
const merged = new Map<string, CursorModelSummary>();
for (const source of sorted) {
for (const entry of source) {
const modelId = entry.modelId.trim();
if (!modelId) {
continue;
}
if (!merged.has(modelId)) {
merged.set(modelId, entry);
}
}
}
return [...merged.values()];
}
export type CursorPickerMode = 'dual' | 'flat'
export type CursorPickerOption = { value: string; label: string }
export type CursorPickerState = {
catalog: CursorModelCatalog
mode: CursorPickerMode
wireId: string | null
baseKey: string | null
modelOptions: CursorPickerOption[]
effortOptions: CursorPickerOption[]
showEffortPicker: boolean
}
/**
* 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) => isCursorAcpCatalogModelId(model.modelId))
}
/**
* Merge session (ACP) and machine catalogs. Session rows win on duplicate modelId;
* machine rows fill display names and extra SKUs.
*/
export function mergeCursorModelSummaries(
primary: readonly CursorModelSummary[],
secondary: readonly CursorModelSummary[],
currentWireId?: string | null
): CursorModelSummary[] {
const merged = new Map<string, CursorModelSummary>()
const add = (model: CursorModelSummary) => {
const modelId = model.modelId.trim()
if (!modelId || !isCursorAcpCatalogModelId(modelId)) {
return
}
if (!merged.has(modelId)) {
merged.set(modelId, { ...model, modelId })
}
}
for (const model of primary) {
add(model)
}
for (const model of secondary) {
add(model)
}
const trimmedCurrent = currentWireId?.trim()
if (trimmedCurrent && isCursorAcpCatalogModelId(trimmedCurrent) && !merged.has(trimmedCurrent)) {
merged.set(trimmedCurrent, { modelId: trimmedCurrent })
}
return [...merged.values()]
}
export function buildCursorCatalogFromSources(args: {
sessionModels: readonly CursorModelSummary[]
machineModels?: readonly CursorModelSummary[]
cliModelSkus?: readonly CursorModelSummary[]
currentWireId?: string | null
sessionModelFromHub?: string | null
defaultValue?: null | 'auto'
}): CursorModelCatalog {
const wireHint = args.currentWireId
?? args.sessionModelFromHub
?? null
const merged = mergeCursorModelSummaries(
args.sessionModels,
args.machineModels ?? [],
wireHint
)
const injectCurrent = wireHint && isCursorAcpCatalogModelId(wireHint) ? wireHint : null
const catalog = buildCursorModelCatalog(pickCursorModelsForPicker(merged), {
currentModel: injectCurrent,
defaultValue: args.defaultValue
})
return appendCliSkusToCatalog(catalog, args.cliModelSkus ?? [])
}
export function normalizeCursorPickerWireId(
wireId: string | null | undefined,
defaultToken: 'auto' | null = null
): string | null {
const trimmed = wireId?.trim()
if (!trimmed || trimmed === 'auto' || trimmed === 'default' || trimmed === 'default[]') {
return defaultToken
}
return trimmed
}
export function buildCursorPickerState(args: {
catalog: CursorModelCatalog
currentWireId?: string | null
defaultValue?: null | 'auto'
}): CursorPickerState {
const defaultToken = args.defaultValue === 'auto' ? 'auto' : null
const wireId = normalizeCursorPickerWireId(args.currentWireId, defaultToken)
const baseKey = wireId && wireId !== 'auto'
? resolveCursorBaseKey(wireId, args.catalog)
: null
const useDual = shouldUseCursorDualPickers(args.catalog, wireId === 'auto' ? null : wireId)
const variantBaseKey = baseKey && baseKey !== 'auto' ? baseKey : null
const variantsForBase = resolveCursorVariantOptions(variantBaseKey, args.catalog)
const showEffortPicker = Boolean(variantBaseKey && variantsForBase.length > 1)
const modelOptions: CursorPickerOption[] = useDual
? args.catalog.baseOptions.map((option) => ({
value: option.value ?? 'auto',
label: option.label
}))
: buildFlatCursorModelPickerOptions(args.catalog, {
defaultValue: args.defaultValue === 'auto' ? 'auto' : undefined
}).map((option) => ({
value: option.value ?? 'auto',
label: option.label
}))
const effortOptions: CursorPickerOption[] = showEffortPicker
? buildCursorEffortPickerOptions(variantsForBase)
: []
return {
catalog: args.catalog,
mode: useDual ? 'dual' : 'flat',
wireId,
baseKey,
modelOptions,
effortOptions,
showEffortPicker
}
}
/**
* When switching base in dual picker, only apply when the base maps to exactly one wire.
* Multi-variant bases require an explicit variant click; no "closest" matching.
*/
export function resolveWireIdForBaseChange(
baseKey: string,
catalog: CursorModelCatalog,
_currentWireId?: string | null
): string | null {
if (baseKey === 'auto') {
return 'auto'
}
const variants = resolveCursorVariantOptions(baseKey, catalog)
if (variants.length === 1) {
return variants[0].wireId
}
return null
}
export function resolveCursorBaseFromWire(
wireId: string,
catalog: CursorModelCatalog
): string {
if (wireId === 'auto') {
return 'auto'
}
return resolveCursorBaseKey(wireId, catalog) ?? cursorModelDedupeKey(wireId)
}