Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,565 changes: 1,494 additions & 71 deletions package-lock.json

Large diffs are not rendered by default.

31 changes: 28 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"url": "https://github.com/stablekernel/opencode-cursor/issues"
},
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0",
"opencode": ">=1.17.0"
},
"keywords": [
"opencode",
Expand All @@ -39,11 +40,18 @@
},
"./server": {
"types": "./dist/plugin/index.d.ts",
"import": "./dist/plugin/index.js"
"import": "./dist/plugin/index.js",
"config": {
"custom": true
}
},
"./plugin": {
"types": "./dist/plugin/index.d.ts",
"import": "./dist/plugin/index.js"
},
"./tui": {
"types": "./dist/tui/index.d.ts",
"import": "./dist/tui/index.js"
}
},
"scripts": {
Expand All @@ -66,13 +74,30 @@
"node-gyp": "^12.4.0"
},
"peerDependencies": {
"@ai-sdk/provider": "^3.0.0"
"@ai-sdk/provider": "^3.0.0",
"@opentui/core": "*",
"@opentui/solid": "*",
"solid-js": "*"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
"@ai-sdk/provider": "^3.0.13",
"@opencode-ai/sdk": "^1.17.14",
"@opentui/core": "^0.4.3",
"@opentui/solid": "^0.4.3",
"@types/node": "^26.0.0",
"@types/semver": "^7.7.1",
"solid-js": "^1.9.12",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
Expand Down
4 changes: 4 additions & 0 deletions src/fallback-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const FALLBACK_MODELS: ModelListItem[] = [
parameters: [
{ id: "thinking", displayName: "Thinking", values: [{ value: "off" }, { value: "on" }] },
],
variants: [
{ params: [{ id: "thinking", value: "off" }], displayName: "Composer 2.5", isDefault: true },
{ params: [{ id: "thinking", value: "on" }], displayName: "Composer 2.5" },
],
},
{ id: "claude-opus-4-8", displayName: "Claude Opus 4.8 (via Cursor)" },
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 (via Cursor)" },
Expand Down
156 changes: 156 additions & 0 deletions src/model-axes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { ModelListItem } from "@cursor/sdk";

export interface ModelAxis {
/** The Cursor param id: "effort" | "reasoning" | "thinking" | "fast" | "context" | ... */
id: string;
/** Human label for UI, e.g. "Effort". */
label: string;
/** Distinct values in stable, meaningful order. */
values: string[];
/** Seed value: from the isDefault variant, else values[0]. */
default: string;
/** ">2 values" = cycle, exactly 2 = toggle. */
kind: "cycle" | "toggle";
}

// Preferred value orderings so cyclers advance in an intuitive direction.
// Any value not listed keeps first-seen order, appended after known ones.
const VALUE_ORDER: Record<string, string[]> = {
effort: ["low", "medium", "high", "xhigh", "max"],
reasoning: ["none", "low", "medium", "high", "extra-high", "xhigh", "max"],
context: ["200k", "272k", "300k", "1m"],
thinking: ["false", "true"],
fast: ["false", "true"],
};

const LABELS: Record<string, string> = {
effort: "Effort",
reasoning: "Reasoning",
thinking: "Thinking",
fast: "Fast",
context: "Context",
};

function orderValues(id: string, seen: string[]): string[] {
const pref = VALUE_ORDER[id];
if (!pref) return seen;
const known = pref.filter((v) => seen.includes(v));
const extra = seen.filter((v) => !pref.includes(v));
return [...known, ...extra];
}

/**
* Group the model's enumerated variants by param id into independent axes.
* A param that only ever has one value across all variants is pinned (not an
* axis). Default per axis is taken from the variant flagged isDefault.
*/
export function buildModelAxes(item: ModelListItem): ModelAxis[] {
const variants = item.variants ?? [];
if (variants.length <= 1) return [];

// Collect distinct values per param id, and the default variant's value.
const valuesById = new Map<string, Set<string>>();
const defaultVariant = variants.find((v) => v.isDefault);
const defaultById = new Map<string, string>();

for (const v of variants) {
for (const p of v.params ?? []) {
let set = valuesById.get(p.id);
if (!set) {
set = new Set<string>();
valuesById.set(p.id, set);
}
set.add(p.value);
}
}
for (const p of defaultVariant?.params ?? []) {
defaultById.set(p.id, p.value);
}

const axes: ModelAxis[] = [];
for (const [id, set] of valuesById) {
if (set.size <= 1) continue; // pinned param (e.g. cyber=false) -> not an axis
const values = orderValues(id, [...set]);
const fallback = values[0];
if (fallback === undefined) continue; // satisfies noUncheckedIndexedAccess
const def = defaultById.get(id) ?? fallback;
axes.push({
id,
label: LABELS[id] ?? id,
values,
default: def,
kind: values.length === 2 ? "toggle" : "cycle",
});
}
return axes;
}

/** Serialize a param map to a stable key for set membership. */
function comboKey(params: Record<string, string>): string {
return Object.keys(params)
.sort()
.map((k) => `${k}=${params[k]}`)
.join("|");
}

/** Build the set of all offered variant combos (over the axis param ids). */
function offeredCombos(item: ModelListItem, axisIds: string[]): Set<string> {
const out = new Set<string>();
for (const v of item.variants ?? []) {
const proj: Record<string, string> = {};
for (const p of v.params ?? []) {
if (axisIds.includes(p.id)) proj[p.id] = p.value;
}
out.add(comboKey(proj));
}
return out;
}

/**
* True if `params` (restricted to the model's axis ids) matches an offered
* variant. Params outside the axis set are ignored.
*/
export function isValidCombo(item: ModelListItem, params: Record<string, string>): boolean {
const axisIds = buildModelAxes(item).map((a) => a.id);
if (axisIds.length === 0) return true;
const proj: Record<string, string> = {};
for (const id of axisIds) {
const val = params[id];
if (val !== undefined) proj[id] = val;
}
return offeredCombos(item, axisIds).has(comboKey(proj));
}

/**
* If `params` is invalid after changing `changedAxisId`, adjust the OTHER axes
* to the nearest offered combo that preserves the changed axis's value. Picks
* the first offered variant whose changed-axis value matches; falls back to the
* default variant if none match. Never mutates the changed axis.
*/
export function snapCombo(
item: ModelListItem,
params: Record<string, string>,
changedAxisId: string,
): Record<string, string> {
if (isValidCombo(item, params)) return params;
const axisIds = buildModelAxes(item).map((a) => a.id);
const target = params[changedAxisId];

// Prefer an offered variant that keeps the changed axis value.
for (const v of item.variants ?? []) {
const proj: Record<string, string> = {};
for (const p of v.params ?? []) {
if (axisIds.includes(p.id)) proj[p.id] = p.value;
}
if (proj[changedAxisId] === target) return proj;
}

// No variant supports the changed value together with anything: fall back to
// the default variant's projection.
const def = (item.variants ?? []).find((v) => v.isDefault) ?? (item.variants ?? [])[0];
const proj: Record<string, string> = {};
for (const p of def?.params ?? []) {
if (axisIds.includes(p.id)) proj[p.id] = p.value;
}
return proj;
}
9 changes: 9 additions & 0 deletions src/model-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ export async function discoverModels(options: DiscoverOptions = {}): Promise<Dis
}
}

/**
* Latest cached raw Cursor catalog (with `variants[]`), or `[]` when nothing is
* cached yet. Synchronous, key-agnostic reader for the TUI half, which resolves
* the active model's `ModelListItem` without an async `discoverModels` round-trip.
*/
export function cachedCatalog(): ModelListItem[] {
return readLatestModelCache() ?? [];
}

/** True when a model exposes a thinking/reasoning parameter. */
export function modelSupportsReasoning(item: ModelListItem): boolean {
return (item.parameters ?? []).some((p) => /think|reason/i.test(p.id));
Expand Down
130 changes: 35 additions & 95 deletions src/model-variants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ModelListItem } from "@cursor/sdk";
import { buildModelAxes } from "./model-axes.js";

/**
* A Cursor model "variant" as opencode stores it: an options object that, when
Expand All @@ -10,109 +11,48 @@ export interface CursorVariant {
mode?: "agent" | "plan";
}

const REASONING_PARAM = /think|reason|effort/i;
const BOOLEAN_VALUES = new Set(["true", "false"]);

function paramValues(param: NonNullable<ModelListItem["parameters"]>[number]): string[] {
return (param.values ?? []).map((v) => v.value);
}

function isBooleanParam(values: string[]): boolean {
return values.length > 0 && values.every((v) => BOOLEAN_VALUES.has(v));
}

/**
* Params opencode must send by DEFAULT for this model — i.e. when the user has
* NOT picked a variant. Non-reasoning boolean toggles (notably Cursor's `fast`)
* are pinned OFF here so the provider never silently inherits Cursor's
* server-side default, which is `fast: true` for several models (composer-*,
* gpt-*-codex). The user opts back IN via the matching picker variant.
*
* Seeded into each model's opencode `options.params` (see `toOpencodeModels` /
* `buildModelV2Map`); {@link resolveControls} merges it into the request.
* Curated flat variant map for opencode's NATIVE variant_cycle (the --pure /
* no-tui fallback). One entry per non-default axis value, varying ONE axis at a
* time from the model defaults — a short list, never the full cartesian product.
*/
export function defaultModelParams(item: ModelListItem): Record<string, string> {
const out: Record<string, string> = {};
for (const param of item.parameters ?? []) {
if (REASONING_PARAM.test(param.id)) continue;
if (isBooleanParam(paramValues(param))) out[param.id] = "false";
export function buildModelVariants(item: ModelListItem): Record<string, CursorVariant> {
const axes = buildModelAxes(item);
const out: Record<string, CursorVariant> = {};
const base: Record<string, string> = {};
for (const a of axes) base[a.id] = a.default;

for (const a of axes) {
for (const value of a.values) {
if (value === a.default) continue;
if (a.kind === "toggle" && value === "false") continue; // off = baseline
const label = variantLabel(a.id, value);
out[label] = { params: { ...base, [a.id]: value } };
}
}
return out;
}

/**
* Derive opencode model variants from a Cursor model's parameters so the
* variant picker can expose thinking/reasoning levels plus the `fast` toggle.
* Each variant's object is exactly what {@link resolveControls} consumes. Plan
* mode is NOT a variant: opencode's plan agent (Tab) is mapped to Cursor's plan
* mode by the plugin's `chat.params` hook.
*
* Every variant for a fast-capable model carries an explicit `fast` value
* (reasoning variants pin it OFF via {@link defaultModelParams}; the `fast`
* variant turns it ON) so a selection never depends on Cursor's server-side
* default for an omitted param.
*/
export function buildModelVariants(item: ModelListItem): Record<string, CursorVariant> {
const out: Record<string, CursorVariant> = {};
// Non-reasoning boolean defaults (e.g. { fast: "false" }), pinned into every
// reasoning variant so picking a reasoning level never re-enables fast.
const defaults = defaultModelParams(item);

// Pre-pass: does any reasoning param expose a non-boolean effort enum (e.g.
// ["low","medium","high","xhigh","max"])? When it does, a coexisting boolean
// reasoning toggle (Cursor's `thinking=["false","true"]` on claude-* models)
// is redundant — selecting any effort level already enables reasoning — and
// surfacing it would add a stray `thinking` variant the standard opencode
// providers don't show. Suppress the boolean variant for parity. Order-
// independent: the enum may be declared before or after the boolean.
const hasEffortEnum = (item.parameters ?? []).some(
(p) => REASONING_PARAM.test(p.id) && !isBooleanParam(paramValues(p)) && paramValues(p).length > 0,
);

for (const param of item.parameters ?? []) {
const values = paramValues(param);
if (values.length === 0) continue;
const boolean = isBooleanParam(values);

if (REASONING_PARAM.test(param.id)) {
if (boolean) {
// Boolean toggle (e.g. thinking=["false","true"]). Literal true/false
// variant names are meaningless in the picker — surface a single
// variant named after the param that switches it on. "Off" is the
// model's default (no variant selected). Skipped entirely when an
// effort enum coexists (see hasEffortEnum above).
if (!hasEffortEnum && values.includes("true")) {
out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } };
}
continue;
}

for (const value of values) {
// `none` means reasoning OFF — the model's default when no variant is
// selected. Surfacing it as a selectable variant is meaningless (you
// get it by picking nothing), so skip it. Standard providers
// (models.dev) include `none` in their effort values, but the
// no-variant-selected state already represents it.
if (value === "none") continue;
// Cursor labels the top reasoning tier "extra-high"; the opencode
// standard (models.dev) calls it "xhigh". Use the standard label for
// the variant key so the cycler is consistent across providers, but
// keep the Cursor wire-format value ("extra-high") in the params sent
// to the API.
const displayKey = value === "extra-high" ? "xhigh" : value;
const key = out[displayKey] === undefined ? displayKey : `${param.id}-${displayKey}`;
out[key] = { params: { ...defaults, [param.id]: value } };
}
continue;
}
function variantLabel(axisId: string, value: string): string {
if (axisId === "thinking" && value === "true") return "thinking";
if (axisId === "fast" && value === "true") return "fast";
if (value === "extra-high") return "xhigh";
return value;
}

// Non-reasoning boolean toggle (e.g. Cursor's `fast`). Default is OFF (see
// defaultModelParams); expose a single opt-in variant that turns it ON.
if (boolean && values.includes("true")) {
out[param.id.toLowerCase()] = { params: { ...defaults, [param.id]: "true" } };
/** Baseline params to seed a model's default request (axis defaults + pinned). */
export function defaultModelParams(item: ModelListItem): Record<string, string> {
const out: Record<string, string> = {};
for (const a of buildModelAxes(item)) out[a.id] = a.default;
// Pinned single-value params (never axes) still belong in the baseline.
const counts = new Map<string, Set<string>>();
for (const v of item.variants ?? [])
for (const p of v.params ?? []) (counts.get(p.id) ?? counts.set(p.id, new Set()).get(p.id)!).add(p.value);
for (const [id, set] of counts) {
if (set.size === 1) {
const only = [...set][0];
if (only !== undefined) out[id] = only;
}
// Non-reasoning enum params (e.g. `context`) remain unsupported in the picker.
}

return out;
}
Loading