Skip to content
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9836ad0
Use ComposerDropdown for provider selector
friuns May 9, 2026
1e98502
Document app interaction component rules
friuns May 9, 2026
5d623d2
Use ComposerDropdown for remaining app selects
friuns May 9, 2026
3f7782e
Configure app-server with selected web port
friuns May 9, 2026
e81b072
Prefer configured model after provider switch
friuns May 9, 2026
b5e5f82
Reset new thread model on provider switch
friuns May 9, 2026
2493c2e
Scope model selections by provider
friuns May 9, 2026
5f109da
Fix Qodo review findings
friuns May 9, 2026
8be5068
Load provider models during background refresh
friuns May 9, 2026
67721b5
Preserve thread provider switching
friuns May 9, 2026
cdf4a08
Keep Zen model when selecting threads
friuns May 9, 2026
94db5ff
Fix provider model dropdown switching
friuns May 9, 2026
e33c850
Preview provider model during switch
friuns May 9, 2026
f939bcb
Limit Zen dropdown to free models
friuns May 9, 2026
5b14251
Add provider switching test checklist
friuns May 9, 2026
7633fbb
Refresh provider state on startup and switch
friuns May 9, 2026
1ea6ef9
Allow custom gpt-prefixed provider models
friuns May 9, 2026
cdeb442
Remove legacy model selection fallbacks
friuns May 9, 2026
31395bf
Keep provider model when switching chats
friuns May 9, 2026
54671ab
Normalize Codex provider model context
friuns May 9, 2026
7e0475a
Show provider model in composer
friuns May 9, 2026
77d3cc8
Respect valid resumed thread models
friuns May 9, 2026
0c8be16
Fix Qodo provider review issues
friuns May 9, 2026
dc7871a
Send visible provider model from composer
friuns May 9, 2026
28f7637
Hydrate provider model from free-mode status
friuns May 9, 2026
6ecfed9
Preserve thread model during provider status sync
friuns May 9, 2026
6491cb3
Update wiki for provider model verification
friuns May 10, 2026
5cbe96c
Keep Zen reasoning replay Responses-safe
friuns May 10, 2026
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@
- For shared route surfaces and large feature UIs, prefer putting the decisive dark-theme overrides in the global theme stylesheet (`src/style.css`) instead of relying only on component-scoped `:global(:root.dark)` blocks.
- Scoped dark overrides are fine for truly local elements, but if a full route still looks like light theme in dark mode, add or strengthen the global selectors for that surface.

## UI Interaction Component Rule

- Never add a normal HTML `<select>` for app UI. Use `ComposerDropdown` by default, or another existing app dropdown component when it is already the established pattern for that surface.
- Never use browser-native `prompt()` for app UI. Use an existing modal, inline editor, drawer, or app-styled form flow instead.
- When replacing legacy `<select>` or `prompt()` usage, preserve the existing behavior and state wiring while moving the interaction into the app component system.

## NPX Testing Rule

- For any `npx` package behavior test, **publish first**, then test the published `@latest` package.
Expand Down
220 changes: 173 additions & 47 deletions src/App.vue

Large diffs are not rendered by default.

48 changes: 47 additions & 1 deletion src/api/codexGateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listDirectoryComposioConnectors, startThreadTurn } from './codexGateway'
import { getCurrentModelConfig, listDirectoryComposioConnectors, startThreadTurn } from './codexGateway'

function mockRpcFetch(): { requests: Array<{ method: string, params: Record<string, unknown> }> } {
const requests: Array<{ method: string, params: Record<string, unknown> }> = []
Expand Down Expand Up @@ -87,3 +87,49 @@ describe('listDirectoryComposioConnectors', () => {
expect(requests).toEqual(['/codex-api/composio/connectors?query=instagram&cursor=50&limit=25'])
})
})

describe('getCurrentModelConfig', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('uses enabled free-mode provider and model over stale Codex config values', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === '/codex-api/free-mode/status') {
return new Response(JSON.stringify({
enabled: true,
provider: 'opencode-zen',
currentModel: 'big-pickle',
wireApi: 'chat',
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}

const body = typeof init?.body === 'string'
? JSON.parse(init.body) as { method: string }
: { method: '' }
expect(body.method).toBe('config/read')
return new Response(JSON.stringify({
result: {
config: {
model: 'gpt-5.4-mini',
model_provider: 'openai',
model_reasoning_effort: 'high',
service_tier: 'auto',
},
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}))

await expect(getCurrentModelConfig()).resolves.toMatchObject({
model: 'big-pickle',
providerId: 'opencode-zen',
reasoningEffort: 'high',
})
})
})
13 changes: 11 additions & 2 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1799,10 +1799,19 @@ export async function getAvailableModelIds(options: { includeProviderModels?: bo

export async function getCurrentModelConfig(): Promise<CurrentModelConfig> {
const payload = await callRpc<ConfigReadResponse>('config/read', {})
const model = payload.config.model ?? ''
const providerId = typeof payload.config.model_provider === 'string' ? payload.config.model_provider : ''
let model = payload.config.model ?? ''
let providerId = typeof payload.config.model_provider === 'string' ? payload.config.model_provider : ''
const reasoningEffort = normalizeReasoningEffort(payload.config.model_reasoning_effort)
const speedMode = normalizeSpeedMode(payload.config.service_tier)
try {
const freeModeStatus = await getFreeModeStatus()
if (freeModeStatus.enabled) {
model = freeModeStatus.currentModel ?? model
providerId = freeModeStatus.provider ?? providerId
}
} catch {
// Keep the app usable when free-mode status is unavailable.
}
return { model, providerId, reasoningEffort, speedMode }
}

Expand Down
7 changes: 6 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,10 +533,15 @@ async function startServer(options: {
const generatedPasswordPath = password && passwordResolution.generated
? await persistGeneratedPassword(password)
: null
const { app, dispose, attachWebSocket } = createApp({ password })
const { app, dispose, attachWebSocket, startBridgeBackgroundServices } = createApp({
password,
deferBridgeBackgroundServices: true,
})
const server = createServer(app)
attachWebSocket(server)
const port = await listenWithFallback(server, requestedPort)
process.env.CODEXUI_SERVER_PORT = String(port)
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
startBridgeBackgroundServices()
let tunnelChild: ReturnType<typeof spawn> | null = null
let tunnelUrl: string | null = null

Expand Down
41 changes: 19 additions & 22 deletions src/components/content/ReviewPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,12 @@

<div v-if="activeScope === 'baseBranch' && snapshot?.baseBranchOptions.length" class="review-pane-control-cluster">
<span class="review-pane-control-label">{{ t('Branch') }}</span>
<label class="review-pane-branch-select-wrap">
<select
v-model="selectedBaseBranch"
class="review-pane-branch-select"
>
<option
v-for="branch in snapshot.baseBranchOptions"
:key="branch"
:value="branch"
>
{{ branch }}
</option>
</select>
</label>
<ComposerDropdown
class="review-pane-branch-select"
:model-value="selectedBaseBranch"
:options="snapshot.baseBranchOptions.map((branch) => ({ value: branch, label: branch }))"
@update:model-value="selectedBaseBranch = $event"
/>
</div>

<div v-if="activeScope === 'workspace'" class="review-pane-control-cluster">
Expand Down Expand Up @@ -418,6 +410,7 @@ import type {
UiReviewHunk,
} from '../../types/codex'
import IconTablerX from '../icons/IconTablerX.vue'
import ComposerDropdown from './ComposerDropdown.vue'

const props = defineProps<{
threadId: string
Expand Down Expand Up @@ -1137,12 +1130,16 @@ onBeforeUnmount(() => {
@apply shrink-0 text-[10px] font-medium uppercase tracking-[0.08em] text-zinc-400;
}

.review-pane-branch-select-wrap {
@apply inline-flex min-w-[9rem] items-center rounded-full border border-zinc-200 bg-white px-2.5 py-1 shadow-sm;
.review-pane-branch-select {
@apply min-w-[9rem];
}

.review-pane-branch-select {
@apply w-full appearance-none bg-transparent text-[11px] font-medium text-zinc-700 outline-none;
.review-pane-branch-select :deep(.composer-dropdown-trigger) {
@apply h-auto rounded-full border border-zinc-200 bg-white px-2.5 py-1 text-[11px] font-medium text-zinc-700 shadow-sm;
}
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.

.review-pane-branch-select :deep(.composer-dropdown-value) {
@apply max-w-44;
}

.review-pane-segmented {
Expand Down Expand Up @@ -1569,12 +1566,12 @@ onBeforeUnmount(() => {
@apply text-[9px];
}

.review-pane-branch-select-wrap {
@apply min-w-0 flex-1 px-2 py-0.75;
.review-pane-branch-select {
@apply min-w-0 flex-1;
}

.review-pane-branch-select {
@apply text-[12px];
.review-pane-branch-select :deep(.composer-dropdown-trigger) {
@apply px-2 py-0.75 text-[12px];
}

.review-pane-segmented {
Expand Down
2 changes: 2 additions & 0 deletions src/components/content/ThreadComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export type SubmitPayload = {
imageUrls: string[]
fileAttachments: FileAttachment[]
skills: Array<{ name: string; path: string }>
selectedModel: string
mode: 'steer' | 'queue'
}

Expand Down Expand Up @@ -936,6 +937,7 @@ function onSubmit(mode: 'steer' | 'queue' = 'steer'): void {
imageUrls: selectedImages.value.map((image) => image.url),
fileAttachments: [...fileAttachments.value],
skills: selectedSkills.value.map((s) => ({ name: s.name, path: s.path })),
selectedModel: props.selectedModel,
mode,
})
clearPersistedDraftForThread(props.activeThreadId)
Expand Down
87 changes: 50 additions & 37 deletions src/components/content/ThreadPendingRequestPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,33 +107,24 @@

<label v-else-if="field.kind === 'boolean'" class="thread-pending-request-select-wrap">
<span class="thread-pending-request-select-label">{{ t('Choice') }}</span>
<select
<ComposerDropdown
class="thread-pending-request-select"
:value="serializeMcpBooleanValue(readMcpElicitationFieldValue(request.id, field))"
@change="onMcpElicitationBooleanChange(request.id, field, $event)"
>
<option v-if="!field.hasExplicitDefault" value="">{{ t('Select true or false') }}</option>
<option value="true">{{ t('True') }}</option>
<option value="false">{{ t('False') }}</option>
</select>
:model-value="serializeMcpBooleanValue(readMcpElicitationFieldValue(request.id, field))"
:options="mcpBooleanOptions(field)"
:placeholder="t('Select true or false')"
@update:model-value="onMcpElicitationBooleanValueChange(request.id, field, $event)"
/>
</label>

<label v-else-if="field.kind === 'singleEnum'" class="thread-pending-request-select-wrap">
<span class="thread-pending-request-select-label">{{ t('Choice') }}</span>
<select
<ComposerDropdown
class="thread-pending-request-select"
:value="String(readMcpElicitationFieldValue(request.id, field) ?? '')"
@change="onMcpElicitationFieldInput(request.id, field, $event)"
>
<option v-if="!field.hasExplicitDefault" value="">{{ t('Select an option') }}</option>
<option
v-for="option in field.options"
:key="`${request.id}:${field.key}:${option.value}`"
:value="option.value"
>
{{ option.label }}
</option>
</select>
:model-value="String(readMcpElicitationFieldValue(request.id, field) ?? '')"
:options="mcpSingleEnumOptions(field)"
:placeholder="t('Select an option')"
@update:model-value="onMcpElicitationFieldValueChange(request.id, field, $event)"
/>
</label>

<div v-else class="thread-pending-request-question-options">
Expand Down Expand Up @@ -182,19 +173,12 @@
<div v-if="question.options.length > 0" class="thread-pending-request-question-options">
<label class="thread-pending-request-select-wrap">
<span class="thread-pending-request-select-label">{{ t('Choice') }}</span>
<select
<ComposerDropdown
class="thread-pending-request-select"
:value="readQuestionAnswer(request.id, question.id, question.options[0]?.label || '')"
@change="onQuestionAnswerChange(request.id, question.id, $event)"
>
<option
v-for="option in question.options"
:key="`${request.id}:${question.id}:${option.label}`"
:value="option.label"
>
{{ option.label }}
</option>
</select>
:model-value="readQuestionAnswer(request.id, question.id, question.options[0]?.label || '')"
:options="toolQuestionOptions(question.options)"
@update:model-value="onQuestionAnswerValueChange(request.id, question.id, $event)"
/>
</label>

<p
Expand Down Expand Up @@ -250,6 +234,7 @@
import { computed, ref, watch } from 'vue'
import type { UiServerRequest, UiServerRequestReply } from '../../types/codex'
import { useUiLanguage } from '../../composables/useUiLanguage'
import ComposerDropdown from './ComposerDropdown.vue'

type ApprovalDecision = 'accept' | 'acceptForSession' | 'decline' | 'cancel'

Expand Down Expand Up @@ -528,17 +513,25 @@ function readQuestionAnswer(requestId: number, questionId: string, fallback: str
return fallback
}

function toolQuestionOptions(options: ParsedToolQuestion['options']): Array<{ value: string; label: string }> {
return options.map((option) => ({ value: option.label, label: option.label }))
}

function readQuestionOtherAnswer(requestId: number, questionId: string): string {
return toolQuestionOtherAnswers.value[toolQuestionKey(requestId, questionId)] ?? ''
}

function onQuestionAnswerChange(requestId: number, questionId: string, event: Event): void {
const target = event.target
if (!(target instanceof HTMLSelectElement)) return
onQuestionAnswerValueChange(requestId, questionId, target.value)
}

function onQuestionAnswerValueChange(requestId: number, questionId: string, value: string): void {
const key = toolQuestionKey(requestId, questionId)
toolQuestionAnswers.value = {
...toolQuestionAnswers.value,
[key]: target.value,
[key]: value,
}
}

Expand Down Expand Up @@ -728,8 +721,10 @@ function readMcpElicitationMultiValue(requestId: number, field: McpElicitationFi
function onMcpElicitationFieldInput(requestId: number, field: McpElicitationField, event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLSelectElement)) return
onMcpElicitationFieldValueChange(requestId, field, target.value)
}

const rawValue = target.value
function onMcpElicitationFieldValueChange(requestId: number, field: McpElicitationField, rawValue: string): void {
const nextValue =
field.kind === 'number'
? rawValue
Expand All @@ -745,10 +740,13 @@ function onMcpElicitationFieldInput(requestId: number, field: McpElicitationFiel
function onMcpElicitationBooleanChange(requestId: number, field: McpElicitationField, event: Event): void {
const target = event.target
if (!(target instanceof HTMLSelectElement)) return
onMcpElicitationBooleanValueChange(requestId, field, target.value)
}

function onMcpElicitationBooleanValueChange(requestId: number, field: McpElicitationField, value: string): void {
let nextValue: boolean | null = null
if (target.value === 'true') nextValue = true
else if (target.value === 'false') nextValue = false
if (value === 'true') nextValue = true
else if (value === 'false') nextValue = false

mcpElicitationAnswers.value = {
...mcpElicitationAnswers.value,
Expand All @@ -757,6 +755,21 @@ function onMcpElicitationBooleanChange(requestId: number, field: McpElicitationF
mcpElicitationValidationError.value = ''
}

function mcpBooleanOptions(field: McpElicitationField): Array<{ value: string; label: string }> {
return [
...(!field.hasExplicitDefault ? [{ value: '', label: t('Select true or false') }] : []),
{ value: 'true', label: t('True') },
{ value: 'false', label: t('False') },
]
}

function mcpSingleEnumOptions(field: McpElicitationField): Array<{ value: string; label: string }> {
return [
...(!field.hasExplicitDefault ? [{ value: '', label: t('Select an option') }] : []),
...field.options,
]
}

function onMcpElicitationMultiToggle(
requestId: number,
field: McpElicitationField,
Expand Down
Loading