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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-10
350 changes: 350 additions & 0 deletions openspec/changes/archive/2026-06-11-add-model-effort-toggle/design.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
## Why

opencode-gui currently lets users choose a provider/model but does not expose opencode TUI model effort controls. For reasoning-capable models, effort controls intelligence, latency, and spend, so GUI users need parity with the TUI `Ctrl+T` workflow without silently changing opencode defaults.

## What Changes

- Add GUI support for model effort selection and cycling for reasoning-capable models.
- Add `Ctrl+T` handling in the VS Code webview input flow to cycle effort for the selected model, matching TUI muscle memory where webview focus allows it.
- Display the currently selected explicit effort near the selected model, while preserving an unset/default state when no effort has been chosen.
- Extend the UI-to-host and core agent contracts so explicit effort/variant can be forwarded to opencode on the **chat prompt** and **edit/resend prompt** flows.
- `executeShell` continues to work as today. The current opencode SDK 1.2.17 `client.session.shell(...)` body has no `variant` field (Discovery Findings §1), so shell sends do not include effort in this change. Shell is therefore a compatibility path: it must remain functional and must not break when an explicit effort is selected in the GUI.
- Validate effort when the selected model changes so invalid prior effort selections are not sent.
- Add tests for default behavior, cycling, model-change validation, and payload propagation.
- No breaking changes to existing provider/model selection, chat send, shell command, or config behavior.

## Capabilities

### New Capabilities
- `model-effort-control`: Users can view, cycle, validate, and send model effort/variant selections from the GUI while preserving opencode defaults unless an explicit effort is selected.

### Modified Capabilities
- None. Existing specs do not cover model/provider selection or send-message behavior.

## Impact

- Affected core contracts:
- `packages/core/src/domain.ts`
- `packages/core/src/protocol.ts`
- Affected opencode agent integration:
- `packages/agents/opencode/src/opencode-agent.ts`
- related opencode-agent tests
- Affected VS Code webview UI and state:
- `packages/platforms/vscode/webview/App.tsx`
- `packages/platforms/vscode/webview/hooks/useProviders.ts`
- `packages/platforms/vscode/webview/components/organisms/InputArea/InputArea.tsx`
- `packages/platforms/vscode/webview/components/molecules/ModelSelector/ModelSelector.tsx`
- locale files if visible labels are added
- Affected VS Code extension host:
- `packages/platforms/vscode/src/chat-view-provider.ts`
- Test impact:
- model selection scenarios
- chat prompt and edit/resend scenarios
- shell command compatibility scenarios (shell must still work when effort is selected, without sending a variant)
- opencode-agent send tests

## Non-Goals

- Do not implement a full provider configuration editor.
- Do not persist effort into `opencode.json` unless opencode already does so through the existing API path.
- Do not invent unsupported provider-specific effort names when metadata is unavailable.
- Do not make disconnected providers/models selectable or alter provider connection behavior.
- Do not change backend model/provider persistence code beyond forwarding an explicit per-request effort/variant when available.

## Risks

- The opencode API may expose effort as model variants, request options, or provider-specific model metadata; implementation must verify the actual SDK/API shape before wiring payloads.
- VS Code or the browser may reserve `Ctrl+T` in some focus contexts; webview capture is expected for the textarea but may need an extension-side command/keybinding fallback.
- Provider effort labels differ (`none/minimal/low/medium/high/xhigh`, `low/medium/high/max`, Anthropic thinking budgets), so hardcoding OpenAI-only values would regress non-OpenAI providers.
- Accidentally sending a guessed effort would change user cost/intelligence defaults.

## Fallback

- If provider metadata does not expose valid efforts, keep effort unset and hide/disable effort cycling for that model.
- If `Ctrl+T` cannot be captured reliably in VS Code webviews, keep the visible effort selector clickable and add a VS Code command/keybinding bridge in a later scoped task.
- If opencode request forwarding does not accept effort directly, map explicit effort to the documented variant/model selection mechanism only after verifying the API surface.

## Compatibility

- Existing users with no explicit GUI effort selection MUST retain current opencode behavior.
- Existing persisted selected model state MUST remain readable.
- Existing `sendMessage`, `editAndResend`, `executeShell`, and model selection flows MUST continue to work when effort is absent.
- `executeShell` MUST continue to work when an explicit effort is selected; the GUI does not include effort in the shell payload in this change because the current `client.session.shell(...)` SDK shape does not support it.
- Existing tests for chat send, shell mode, model selection, and provider display MUST continue to pass.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## 1. Discovery And Contract Shape

- [X] 1.1 Inspect opencode provider metadata and SDK prompt request shape for effort/variant support; record findings in `design.md` Open Questions or a short implementation note; verify by running a focused script or test-safe logging command that does not expose secrets.
- [X] 1.2 Add core type support for optional explicit model effort/variant without changing default model payload semantics; verify with `pnpm --filter @opencodegui/core test` or the nearest available TypeScript/test command.
- [X] 1.3 Add normalized effort-choice helper(s) that derive valid efforts only from verified provider/model metadata; verify with unit tests for supported metadata, unsupported metadata, unset default, and model-change invalidation cases.

## 2. Webview State And UI

- [X] 2.1 Extend provider/model state so the GUI can track an optional explicit effort for the selected model and clear it when invalid for the newly selected model; verify with hook/component tests.
- [X] 2.2 Display explicit effort compactly next to the selected model label while showing no effort text when the state is unset; verify model selector tests still pass.
- [X] 2.3 Add `Ctrl+T` handling in the message input to cycle valid efforts only when supported; verify shortcut behavior with React Testing Library and ensure Enter, IME, popup navigation, and input history tests still pass.

## 3. Protocol And Agent Forwarding

- [X] 3.1 Extend webview-to-extension protocol messages for `sendMessage` and `editAndResend` to carry explicit effort only when selected; verify existing payload tests assert effort is omitted by default. `executeShell` is intentionally not extended in this change because the current `client.session.shell(...)` SDK shape does not support `variant`.
- [X] 3.2 Forward explicit effort through `chat-view-provider` into `IAgent` options for the chat and edit/resend prompt paths without altering calls where effort is absent; verify extension-host tests for send and edit/resend paths.
- [X] 3.3 Map explicit effort into the verified opencode `promptAsync` request shape in `opencode-agent` (top-level `variant: effort.id` sibling of `model`); verify opencode-agent tests cover default omission and explicit effort forwarding.
- [X] 3.4 Verify shell compatibility: `executeShell` continues to use only `{ providerID, modelID }`, does not include `variant`, and does not fail when an explicit effort is selected in the GUI.

## 4. End-To-End Verification

- [X] 4.1 Add or update scenario tests for default unset effort, `Ctrl+T` cycling, model-change invalidation, visible effort label, and no hardcoded effort for unsupported models; verify focused webview tests pass.
- [X] 4.2 Run repository verification for affected packages: focused tests, TypeScript/build command if available, and `pnpm test -- 06-model-selection` or closest convention; record any pre-existing unrelated failures.
- [X] 4.3 Build, package, and install the VSIX for manual verification; verify in VS Code that `Ctrl+T` cycles effort in the webview textarea, selected effort is visible, and prompts without explicit effort retain default behavior.
104 changes: 104 additions & 0 deletions openspec/specs/model-effort-control/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# model-effort-control Specification

## Purpose
TBD - created by archiving change add-model-effort-toggle. Update Purpose after archive.
## Requirements
### Requirement: Preserve default effort until explicitly selected
The GUI SHALL preserve opencode's default model effort behavior when the user has not explicitly selected an effort in the GUI.

#### Scenario: Sending without explicit effort
- **WHEN** a user sends a chat message after selecting a model but before selecting or cycling effort
- **THEN** the GUI SHALL send the model without an explicit effort override
- **AND** opencode SHALL apply its server/config/default effort behavior

#### Scenario: Existing selected model state remains valid
- **WHEN** persisted UI state contains only a provider/model selection from a prior GUI version
- **THEN** the GUI SHALL load the selected model without requiring effort data
- **AND** the next prompt SHALL not include an explicit effort override until the user chooses one

### Requirement: Cycle valid effort choices with Ctrl+T
The GUI SHALL support cycling model effort from the message input with `Ctrl+T` when a selected model exposes valid effort choices.

#### Scenario: Cycling effort for a supported model
- **WHEN** focus is in the message input and the selected model has multiple valid effort choices
- **AND** the user presses `Ctrl+T`
- **THEN** the GUI SHALL prevent the browser/default key behavior for that event
- **AND** select the next valid effort in the model's effort order
- **AND** display the selected effort near the selected model

#### Scenario: Cycling from unset default
- **WHEN** the selected model has valid effort choices and the current GUI effort state is unset
- **AND** the user presses `Ctrl+T`
- **THEN** the GUI SHALL choose the first explicit effort in that model's valid effort order
- **AND** subsequent `Ctrl+T` presses SHALL continue cycling through the same valid effort order

#### Scenario: Unsupported model
- **WHEN** focus is in the message input and the selected model does not expose valid effort choices
- **AND** the user presses `Ctrl+T`
- **THEN** the GUI SHALL not send any effort override
- **AND** existing text input and send behavior SHALL remain unchanged

### Requirement: Validate effort when the selected model changes
The GUI SHALL validate the selected explicit effort against the currently selected model before displaying or sending it.

#### Scenario: New model supports the same effort
- **WHEN** the user has selected an explicit effort
- **AND** changes to another model that supports the same effort identifier
- **THEN** the GUI MAY keep that explicit effort selected
- **AND** the next prompt SHALL include that effort override

#### Scenario: New model does not support prior effort
- **WHEN** the user has selected an explicit effort
- **AND** changes to another model that does not support that effort identifier
- **THEN** the GUI SHALL clear the explicit effort selection
- **AND** the next prompt SHALL omit effort override unless the user chooses a valid effort for the new model

### Requirement: Send explicit effort through GUI protocol
The GUI SHALL propagate explicit effort selections through the webview-to-extension and agent send contracts for chat prompts and edit/resend prompts.

#### Scenario: Chat prompt with explicit effort
- **WHEN** a user sends a chat message with an explicit effort selected
- **THEN** the webview SHALL include that effort in the send message payload
- **AND** the extension host SHALL pass that effort to the opencode agent integration
- **AND** the opencode agent integration SHALL pass the effort to opencode using the verified API-compatible mechanism (top-level `variant` sibling of `model` on `client.session.promptAsync`)

#### Scenario: Edit and resend preserves explicit effort behavior
- **WHEN** a user edits and resends a message while an explicit effort is selected
- **THEN** the resend path SHALL preserve the same explicit effort behavior as normal message send

#### Scenario: Shell command with explicit effort selected
- **WHEN** a user executes a shell command while an explicit GUI effort is selected
- **THEN** the shell path SHALL preserve existing behavior
- **AND** SHALL not send an unsupported effort/variant to `client.session.shell(...)`
- **AND** SHALL not fail because effort is selected

### Requirement: Discover effort choices from provider metadata
The GUI SHALL derive valid effort choices from opencode/provider model metadata rather than hardcoding a single provider's effort list.

#### Scenario: Metadata exposes model variants
- **WHEN** provider model metadata exposes effort or variant choices
- **THEN** the GUI SHALL use those choices to populate the effort cycle for that model
- **AND** labels SHALL reflect the provider/model metadata where available

#### Scenario: Metadata does not expose choices
- **WHEN** provider model metadata does not expose effort or variant choices
- **THEN** the GUI SHALL treat effort as unsupported for that model
- **AND** SHALL not guess effort values from provider or model names

### Requirement: Keep existing model selector behavior
The model effort control SHALL not regress existing model selector behavior.

#### Scenario: Model search still filters models
- **WHEN** a user searches in the model selector
- **THEN** existing connected-provider filtering and no-results behavior SHALL continue to work

#### Scenario: Selecting a model still closes popover
- **WHEN** a user selects a model from the model selector
- **THEN** the GUI SHALL call the existing model select behavior
- **AND** close the popover as before

#### Scenario: Disconnected providers remain disabled
- **WHEN** disconnected providers are shown through existing show-all behavior
- **THEN** their model items SHALL remain disabled
- **AND** effort controls SHALL not make disconnected models selectable

68 changes: 68 additions & 0 deletions packages/agents/opencode/src/__tests__/opencode-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@ describe("OpenCodeAgent", () => {
model: undefined,
agent: undefined,
});
// Default (no explicit effort) must NOT include a `variant` key
// so the opencode server applies its own default behavior.
const call = mockClient.session.promptAsync.mock.calls[0][0];
expect(Object.hasOwn(call, "variant")).toBe(false);
});

it("should send message with model via options", async () => {
Expand All @@ -323,6 +327,64 @@ describe("OpenCodeAgent", () => {
model,
agent: undefined,
});
// Model set without explicit effort must NOT include a `variant` key.
const call = mockClient.session.promptAsync.mock.calls[0][0];
expect(Object.hasOwn(call, "variant")).toBe(false);
});

it("should forward explicit effort as top-level variant sibling of model", async () => {
await agent.connect();
const model = { providerID: "openai", modelID: "gpt-5.4" };
const effort = { id: "low" };

await agent.sendMessage("sess-1", "Hello", { model, effort });

expect(mockClient.session.promptAsync).toHaveBeenCalledWith({
sessionID: "sess-1",
parts: [{ type: "text", text: "Hello" }],
model,
agent: undefined,
variant: "low",
});
// variant lives at the top level — never inside model
const call = mockClient.session.promptAsync.mock.calls[0][0];
expect(call.variant).toBe("low");
expect((call.model as { variant?: unknown })?.variant).toBeUndefined();
});

it("should drop variant when effort is null or has empty id", async () => {
await agent.connect();
const model = { providerID: "openai", modelID: "gpt-5.4" };

await agent.sendMessage("sess-1", "Hello", { model, effort: { id: "" } });

const call = mockClient.session.promptAsync.mock.calls[0][0];
expect(Object.hasOwn(call, "variant")).toBe(false);
});

it("should keep explicit effort alongside files/agent/skill parts", async () => {
await agent.connect();
agent.workspaceFolder = "/ws";
const model = { providerID: "openai", modelID: "gpt-5.4" };
const effort = { id: "high" };
const files = [{ filePath: "a.ts", fileName: "a.ts" }];

await agent.sendMessage("sess-1", "Review", {
model,
effort,
files,
agent: "reviewer",
skill: "coding-guidelines",
});

const call = mockClient.session.promptAsync.mock.calls[0][0];
expect(call.model).toEqual(model);
expect(call.variant).toBe("high");
expect(call.parts).toHaveLength(4);
expect(call.parts[0]).toEqual({ type: "text", text: "/coding-guidelines", synthetic: true });
expect(call.parts[1]).toEqual({ type: "text", text: "Review" });
expect(call.parts[2].type).toBe("file");
expect(call.parts[3]).toEqual({ type: "agent", name: "reviewer" });
});

it("should convert relative file paths to absolute using workspaceFolder", async () => {
Expand Down Expand Up @@ -424,6 +486,9 @@ describe("OpenCodeAgent", () => {
command: "ls -la",
model,
});
// SDK 1.2.17 shell body has no `variant` — must stay absent.
const call = mockClient.session.shell.mock.calls[0][0];
expect(Object.hasOwn(call, "variant")).toBe(false);
});

it("should pass undefined model when not provided", async () => {
Expand All @@ -437,6 +502,9 @@ describe("OpenCodeAgent", () => {
command: "pwd",
model: undefined,
});
// Defensive: no variant key on shell request, even when no model is set.
const call = mockClient.session.shell.mock.calls[0][0];
expect(Object.hasOwn(call, "variant")).toBe(false);
});
});

Expand Down
6 changes: 6 additions & 0 deletions packages/agents/opencode/src/opencode-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,17 @@ export class OpenCodeAgent implements IAgent {
parts.push({ type: "agent", name: options.agent });
}

// SDK 1.2.17 `client.session.promptAsync` exposes `variant?: string` as a
// top-level sibling of `model` (verified in design.md Discovery Findings §1).
// Omit the key entirely when no explicit effort is selected so the opencode
// server applies its own default rather than a GUI-injected override.
const effortId = options?.effort?.id;
await client.session.promptAsync({
sessionID: sessionId,
parts,
model: options?.model,
agent: options?.primaryAgent,
...(effortId ? { variant: effortId } : {}),
});
}

Expand Down
7 changes: 5 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json"
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"test:all": "vitest run"
},
"devDependencies": {
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^4.0.18"
}
}
Loading
Loading