Skip to content
Merged
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
11 changes: 11 additions & 0 deletions agents/__tests__/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ describe('editor agent', () => {
expect(glmEditor.model).toBe('z-ai/glm-5.1')
})

test('creates minimax editor', () => {
const minimaxEditor = createCodeEditor({ model: 'minimax' })
expect(minimaxEditor.model).toBe('minimax/minimax-m2.7')
})

test('gpt-5 editor does not include think tags in instructions', () => {
const gpt5Editor = createCodeEditor({ model: 'gpt-5' })
expect(gpt5Editor.instructionsPrompt).not.toContain('<think>')
Expand All @@ -79,6 +84,12 @@ describe('editor agent', () => {
expect(glmEditor.instructionsPrompt).not.toContain('</think>')
})

test('minimax editor does not include think tags in instructions', () => {
const minimaxEditor = createCodeEditor({ model: 'minimax' })
expect(minimaxEditor.instructionsPrompt).not.toContain('<think>')
expect(minimaxEditor.instructionsPrompt).not.toContain('</think>')
})

test('opus editor includes think tags in instructions', () => {
const opusEditor = createCodeEditor({ model: 'opus' })
expect(opusEditor.instructionsPrompt).toContain('<think>')
Expand Down
6 changes: 4 additions & 2 deletions agents/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { publisher } from '../constants'
import type { AgentDefinition } from '../types/agent-definition'

export const createCodeEditor = (options: {
model: 'gpt-5' | 'opus' | 'glm'
model: 'gpt-5' | 'opus' | 'glm' | 'minimax'
}): Omit<AgentDefinition, 'id'> => {
const { model } = options
return {
publisher,
model:
options.model === 'gpt-5'
? 'openai/gpt-5.1'
: options.model === 'minimax'
? 'minimax/minimax-m2.7'
: options.model === 'glm'
? 'z-ai/glm-5.1'
: 'anthropic/claude-opus-4.7',
Expand Down Expand Up @@ -65,7 +67,7 @@ OR for new files or major rewrites:
}
</codebuff_tool_call>

${model === 'gpt-5' || model === 'glm'
${model === 'gpt-5' || model === 'glm' || model === 'minimax'
? ''
: `Before you start writing your implementation, you should use <think> tags to think about the best way to implement the changes.

Expand Down
2 changes: 0 additions & 2 deletions agents/types/agent-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,6 @@ export type ModelName =
// Other open source models
| 'moonshotai/kimi-k2'
| 'moonshotai/kimi-k2:nitro'
| 'moonshotai/kimi-k2.5'
| 'moonshotai/kimi-k2.5:nitro'
| 'z-ai/glm-5'
| 'z-ai/glm-5.1'
| 'z-ai/glm-4.6'
Expand Down
71 changes: 57 additions & 14 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,28 @@ import { useKeyboard } from '@opentui/react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'

import { Button } from './button'
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
import {
DEFAULT_FREEBUFF_MODEL_ID,
FREEBUFF_DEPLOYMENT_HOURS_LABEL,
FREEBUFF_GLM_MODEL_ID,
FREEBUFF_MODELS,
isFreebuffModelAvailable,
} from '@codebuff/common/constants/freebuff-models'

import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
import { useNow } from '../hooks/use-now'
import { useFreebuffModelStore } from '../state/freebuff-model-store'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
import { useTheme } from '../hooks/use-theme'

import type { KeyEvent } from '@opentui/core'

const FREEBUFF_MODEL_SELECTOR_MODELS = [
...FREEBUFF_MODELS.filter((model) => model.id === FREEBUFF_GLM_MODEL_ID),
...FREEBUFF_MODELS.filter((model) => model.id !== FREEBUFF_GLM_MODEL_ID),
]

/**
* Dual-purpose model picker:
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
Expand All @@ -33,7 +45,9 @@ export const FreebuffModelSelector: React.FC = () => {
const theme = useTheme()
const { terminalWidth } = useTerminalDimensions()
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
const session = useFreebuffSessionStore((s) => s.session)
const now = useNow(60_000)
const [pending, setPending] = useState<string | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
// Keyboard cursor — separate from the actually-selected model so that
Expand All @@ -45,6 +59,15 @@ export const FreebuffModelSelector: React.FC = () => {
setFocusedId(selectedModel)
}, [selectedModel])

useEffect(() => {
if (
(session?.status === 'none' || !session) &&
!isFreebuffModelAvailable(selectedModel, new Date(now))
) {
setSelectedModel(DEFAULT_FREEBUFF_MODEL_ID)
}
}, [now, selectedModel, session, setSelectedModel])

// Landing ('none'): depths come from the server snapshot, no "self" to
// subtract. In-queue ('queued'): for the user's queue, "ahead" is
// `position - 1` (themselves don't count); for every other queue, switching
Expand Down Expand Up @@ -85,18 +108,22 @@ export const FreebuffModelSelector: React.FC = () => {
)

// Decide row vs column layout based on whether both buttons actually fit
// side-by-side. Each button's inner text is "● {displayName} · {tagline} {hint}",
// side-by-side. Each button's inner text is
// "● {displayName} · {tagline} · {hours} {hint}",
// plus 2 cols of border and 2 cols of padding. Buttons are separated by a
// gap of 2. If the total exceeds the terminal width, stack vertically.
const stackVertically = useMemo(() => {
const BUTTON_CHROME = 4 // 2 border + 2 padding
const GAP = 2
const total = FREEBUFF_MODELS.reduce((sum, model, idx) => {
const total = FREEBUFF_MODEL_SELECTOR_MODELS.reduce((sum, model, idx) => {
const inner =
2 /* indicator + space */ +
model.displayName.length +
3 /* " · " */ +
model.tagline.length +
(model.availability === 'deployment_hours'
? 3 + FREEBUFF_DEPLOYMENT_HOURS_LABEL.length
: 0) +
2 /* " " */ +
hintWidth
return sum + inner + BUTTON_CHROME + (idx > 0 ? GAP : 0)
Expand All @@ -115,10 +142,11 @@ export const FreebuffModelSelector: React.FC = () => {
(modelId: string) => {
if (pending) return
if (modelId === committedModelId) return
if (!isFreebuffModelAvailable(modelId, new Date(now))) return
setPending(modelId)
joinFreebuffQueue(modelId).finally(() => setPending(null))
},
[pending, committedModelId],
[pending, committedModelId, now],
)

// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
Expand All @@ -136,25 +164,30 @@ export const FreebuffModelSelector: React.FC = () => {
const isCommit = name === 'return' || name === 'enter' || name === 'space'
if (!isForward && !isBackward && !isCommit) return
if (isCommit) {
if (focusedId !== committedModelId) {
if (
focusedId !== committedModelId &&
isFreebuffModelAvailable(focusedId, new Date(now))
) {
key.preventDefault?.()
pick(focusedId)
}
return
}
const currentIdx = FREEBUFF_MODELS.findIndex((m) => m.id === focusedId)
const currentIdx = FREEBUFF_MODEL_SELECTOR_MODELS.findIndex(
(m) => m.id === focusedId,
)
if (currentIdx === -1) return
const len = FREEBUFF_MODELS.length
const len = FREEBUFF_MODEL_SELECTOR_MODELS.length
const nextIdx = isForward
? (currentIdx + 1) % len
: (currentIdx - 1 + len) % len
const target = FREEBUFF_MODELS[nextIdx]
const target = FREEBUFF_MODEL_SELECTOR_MODELS[nextIdx]
if (target) {
key.preventDefault?.()
setFocusedId(target.id)
}
},
[pending, pick, focusedId, committedModelId],
[pending, pick, focusedId, committedModelId, now],
),
)

Expand All @@ -173,23 +206,30 @@ export const FreebuffModelSelector: React.FC = () => {
alignItems: 'flex-start',
}}
>
{FREEBUFF_MODELS.map((model) => {
{FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => {
// 'Selected' means the dot is filled and the label is bold. On the
// landing screen ('none') this tracks the pre-focused pick; on the
// queued screen it tracks the model the server has us on. Either
// way, selectedModel reflects the intent of "what Enter commits to."
const isSelected = model.id === selectedModel
const isHovered = hoveredId === model.id
const isFocused = focusedId === model.id && !isSelected
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
const indicator = isSelected ? '●' : '○'
const indicatorColor = isSelected ? theme.primary : theme.muted
const labelColor = isSelected ? theme.foreground : theme.muted
const labelColor = isSelected && isAvailable ? theme.foreground : theme.muted
// Clickable whenever picking would actually do something — i.e.
// anything except re-picking the queue we're already in.
const interactable = !pending && model.id !== committedModelId
const interactable = !pending && isAvailable && model.id !== committedModelId
const ahead = aheadByModel?.[model.id]
const hint =
ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ahead} ahead`
!isAvailable
? 'Closed'
: ahead === undefined
? ''
: ahead === 0
? 'No wait'
: `${ahead} ahead`

const borderColor = isSelected
? theme.primary
Expand All @@ -202,7 +242,7 @@ export const FreebuffModelSelector: React.FC = () => {
key={model.id}
onClick={() => {
setFocusedId(model.id)
pick(model.id)
if (isAvailable) pick(model.id)
}}
onMouseOver={() => interactable && setHoveredId(model.id)}
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
Expand All @@ -223,6 +263,9 @@ export const FreebuffModelSelector: React.FC = () => {
{model.displayName}
</span>
<span fg={theme.muted}> · {model.tagline}</span>
{model.availability === 'deployment_hours' && (
<span fg={theme.muted}> · {FREEBUFF_DEPLOYMENT_HOURS_LABEL}</span>
)}
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
</text>
</Button>
Expand Down
2 changes: 1 addition & 1 deletion cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
⚠ Account unavailable
</text>
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
This account can't use freebuff. If you think this is a
This account has been suspended and can't use freebuff. If you think this is a
mistake, contact support@codebuff.com. Press Ctrl+C to exit.
</text>
</>
Expand Down
20 changes: 16 additions & 4 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from '@codebuff/common/env'
import { DEFAULT_FREEBUFF_MODEL_ID } from '@codebuff/common/constants/freebuff-models'
import { useEffect } from 'react'

import {
Expand Down Expand Up @@ -75,14 +76,18 @@ async function callSession(
return body
}
}
// 409 from POST means the user picked a different model than their active
// session is bound to. Surface as a non-throw `model_locked` so the UI can
// show a confirmation prompt (DELETE then re-POST to switch).
// 409 from POST means the selected model cannot be joined right now, either
// because an active session is locked to another model or because a
// Surface model-switch conflicts and temporary model availability closures
// as non-throw states.
if (resp.status === 409 && method === 'POST') {
const body = (await resp.json().catch(() => null)) as
| FreebuffSessionResponse
| null
if (body && body.status === 'model_locked') {
if (
body &&
(body.status === 'model_locked' || body.status === 'model_unavailable')
) {
return body
}
}
Expand Down Expand Up @@ -119,6 +124,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
case 'country_blocked':
case 'banned':
case 'model_locked':
case 'model_unavailable':
return null
}
}
Expand Down Expand Up @@ -398,6 +404,12 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
schedule(0)
return
}
if (next.status === 'model_unavailable') {
useFreebuffModelStore.getState().setSelectedModel(DEFAULT_FREEBUFF_MODEL_ID)
nextMethod = 'GET'
schedule(0)
return
}

// Startup takeover: the initial probe GET saw we already hold a seat
// (from a prior CLI instance). POST now to rotate our instance id so
Expand Down
6 changes: 3 additions & 3 deletions cli/src/state/freebuff-model-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
DEFAULT_FREEBUFF_MODEL_ID,
resolveFreebuffModel,
resolveAvailableFreebuffModel,
} from '@codebuff/common/constants/freebuff-models'
import { create } from 'zustand'

Expand All @@ -24,11 +24,11 @@ interface FreebuffModelStore {
}

export const useFreebuffModelStore = create<FreebuffModelStore>((set) => ({
selectedModel: resolveFreebuffModel(
selectedModel: resolveAvailableFreebuffModel(
loadFreebuffModelPreference() ?? DEFAULT_FREEBUFF_MODEL_ID,
),
setSelectedModel: (model) => {
const resolved = resolveFreebuffModel(model)
const resolved = resolveAvailableFreebuffModel(model)
saveFreebuffModelPreference(resolved)
set({ selectedModel: resolved })
},
Expand Down
2 changes: 1 addition & 1 deletion cli/src/utils/local-agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export const loadAgentDefinitions = (): AgentDefinition[] => {
}

// Override the model of free-mode agents to match the user's pick from the
// freebuff waiting room. Bundled definitions hardcode glm-5.1; we swap in
// freebuff waiting room. Bundled definitions hardcode a free model; we swap in
// whatever the user chose so the chat-completions request body carries the
// matching model and the server-side session gate doesn't reject it as a
// model mismatch.
Expand Down
15 changes: 12 additions & 3 deletions common/src/constants/free-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export const FREEBUFF_ROOT_AGENT_IDS = ['base2-free'] as const
*/
export const FREE_MODE_AGENT_MODELS: Record<string, Set<string>> = {
// Root orchestrator
'base2-free': new Set(['minimax/minimax-m2.7', 'z-ai/glm-5.1']),
'base2-free': new Set([
'minimax/minimax-m2.7',
'z-ai/glm-5.1',
]),

// File exploration agents
'file-picker': new Set(['google/gemini-2.5-flash-lite']),
Expand All @@ -41,10 +44,16 @@ export const FREE_MODE_AGENT_MODELS: Record<string, Set<string>> = {
'basher': new Set(['google/gemini-3.1-flash-lite-preview']),

// Editor for free mode
'editor-lite': new Set(['minimax/minimax-m2.7', 'z-ai/glm-5.1']),
'editor-lite': new Set([
'minimax/minimax-m2.7',
'z-ai/glm-5.1',
]),

// Code reviewer for free mode
'code-reviewer-lite': new Set(['minimax/minimax-m2.7', 'z-ai/glm-5.1']),
'code-reviewer-lite': new Set([
'minimax/minimax-m2.7',
'z-ai/glm-5.1',
]),
}

/**
Expand Down
Loading
Loading