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
4 changes: 3 additions & 1 deletion cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ const AuthedSurface = ({
// 'queued' → waiting our turn
// 'country_blocked' → terminal region-gate message
// 'banned' → terminal account-banned message
// 'rate_limited' → hit per-model session quota; terminal for this run
//
// 'ended' deliberately falls through to <Chat>: the agent may still be
// finishing work under the server-side grace period, and the chat surface
Expand All @@ -390,7 +391,8 @@ const AuthedSurface = ({
session.status === 'queued' ||
session.status === 'none' ||
session.status === 'country_blocked' ||
session.status === 'banned')
session.status === 'banned' ||
session.status === 'rate_limited')
) {
return <WaitingRoomScreen session={session} error={sessionError} />
}
Expand Down
6 changes: 5 additions & 1 deletion cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ export const Chat = ({
})
const hasSubscription = subscriptionData?.hasSubscription ?? false

const { adData, recordImpression } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
const { adData, recordImpression } = useGravityAd({
enabled: IS_FREEBUFF || !hasSubscription,
provider: 'gravity',
fallbackProvider: 'carbon',
})

// Set initial mode from CLI flag on mount
useEffect(() => {
Expand Down
76 changes: 62 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 {
FALLBACK_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,20 @@ export const FreebuffModelSelector: React.FC = () => {
setFocusedId(selectedModel)
}, [selectedModel])

useEffect(() => {
// Landing-screen safety net: if the in-memory selection becomes
// unavailable (e.g. deployment hours close while the picker is open),
// swap to the always-available fallback so Enter doesn't POST a model
// the server will immediately reject. In-memory only — the user's saved
// preference (e.g. GLM) is preserved for the next launch.
if (
(session?.status === 'none' || !session) &&
!isFreebuffModelAvailable(selectedModel, new Date(now))
) {
setSelectedModel(FALLBACK_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 +113,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 +147,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 +169,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 +211,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 +247,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 +268,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
54 changes: 51 additions & 3 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ const formatElapsed = (ms: number): string => {
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
}

/** "in ~3h 20m" / "in ~45 min" / "in under a minute". Used on the
* rate-limited screen so users know when they can try again. */
const formatRetryAfter = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return 'any moment now'
const minutes = Math.round(ms / 60_000)
if (minutes < 1) return 'under a minute'
if (minutes < 60) return `${minutes} min`
const hours = Math.floor(minutes / 60)
const rem = minutes % 60
return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`
}

export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
session,
error,
Expand Down Expand Up @@ -72,11 +84,12 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
// Always enable ads in the waiting room — this is where monetization lives.
// forceStart bypasses the "wait for first user message" gate inside the hook,
// which would otherwise block ads here since no conversation exists yet.
// Uses Carbon (BuySellAds); in-chat ads still use the Gravity default.
// Try Gravity first, then fall back to Carbon when Gravity doesn't fill.
const { adData, recordImpression } = useGravityAd({
enabled: true,
forceStart: true,
provider: 'carbon',
provider: 'gravity',
fallbackProvider: 'carbon',
})

useFreebuffCtrlCExit()
Expand Down Expand Up @@ -216,6 +229,18 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
<span>Elapsed </span>
{formatElapsed(elapsedMs)}
</text>
{/* Per-model session quota (e.g. GLM 5.1 caps at 5/20h). Only
rendered for rate-limited models so the Minimax queue stays
clutter-free. */}
{session.rateLimit && (
<text style={{ fg: theme.muted, alignSelf: 'flex-start' }}>
<span>Sessions </span>
<span fg={theme.foreground}>
{session.rateLimit.recentCount} / {session.rateLimit.limit}
</span>
<span> used in last {session.rateLimit.windowHours}h</span>
</text>
)}
</box>
</>
)}
Expand Down Expand Up @@ -253,11 +278,34 @@ 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>
</>
)}

{/* Per-model session quota exhausted (e.g. 5+ GLM sessions in the
last 20h). Terminal for this run — the user can exit and come
back once the oldest session in the window rolls off. */}
{session?.status === 'rate_limited' && (
<>
<text style={{ fg: theme.secondary, marginBottom: 1 }}>
⚠ Session limit reached
</text>
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
You've used{' '}
<span fg={theme.foreground}>
{session.recentCount} of {session.limit}
</span>{' '}
hour-long sessions on {session.model} in the last{' '}
{session.windowHours}h. Try again in{' '}
<span fg={theme.foreground}>
{formatRetryAfter(session.retryAfterMs)}
</span>
. Press Ctrl+C to exit.
</text>
</>
)}
</box>
</box>

Expand Down
Loading
Loading