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: 10 additions & 1 deletion cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ import { reportActivity } from './utils/activity-tracker'
import { trackEvent } from './utils/analytics'
import { showClipboardMessage } from './utils/clipboard'
import { readClipboardImage } from './utils/clipboard-image'
import { IS_FREEBUFF } from './utils/constants'
import { endAndRejoinFreebuffSession } from './hooks/use-freebuff-session'
import { END_SESSION_MESSAGE, IS_FREEBUFF } from './utils/constants'
import { getSystemMessage } from './utils/message-history'
import { getInputModeConfig } from './utils/input-modes'

import {
Expand Down Expand Up @@ -1453,6 +1455,13 @@ export const Chat = ({
scrollToLatest={scrollToLatest}
statusIndicatorState={statusIndicatorState}
onStop={chatKeyboardHandlers.onInterruptStream}
onEndSession={() => {
setMessages((prev) => [
...prev,
getSystemMessage(END_SESSION_MESSAGE),
])
endAndRejoinFreebuffSession().catch(() => {})
}}
freebuffSession={freebuffSession}
/>
)}
Expand Down
27 changes: 24 additions & 3 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@ import { CLAUDE_OAUTH_ENABLED } from '@codebuff/common/constants/claude-oauth'
import { safeOpen } from '../utils/open-url'

import { handleAdsEnable, handleAdsDisable } from './ads'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
import { useThemeStore } from '../hooks/use-theme'
import { handleHelpCommand } from './help'
import { handleImageCommand } from './image'
import { handleInitializationFlowLocally } from './init'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
import { runBashCommand } from './router'
import { handleUsageCommand } from './usage'
import { endAndRejoinFreebuffSession } from '../hooks/use-freebuff-session'
import { useThemeStore } from '../hooks/use-theme'
import { WEBSITE_URL } from '../login/constants'
import { useChatStore } from '../state/chat-store'
import { useFeedbackStore } from '../state/feedback-store'
import { useLoginStore } from '../state/login-store'
import { getChatGptOAuthStatus } from '../utils/chatgpt-oauth'
import { AGENT_MODES, IS_FREEBUFF } from '../utils/constants'
import { AGENT_MODES, END_SESSION_MESSAGE, IS_FREEBUFF } from '../utils/constants'
import { getSystemMessage, getUserMessage } from '../utils/message-history'
import { capturePendingAttachments } from '../utils/pending-attachments'
import { getSkillByName } from '../utils/skill-registry'
Expand Down Expand Up @@ -178,6 +179,7 @@ const FREEBUFF_REMOVED_COMMANDS = new Set([
const FREEBUFF_ONLY_COMMANDS = new Set([
'connect',
'plan',
'end-session',
])

const ALL_COMMANDS: CommandDefinition[] = [
Expand Down Expand Up @@ -611,6 +613,25 @@ const ALL_COMMANDS: CommandDefinition[] = [
clearInput(params)
},
}),
// /end-session (freebuff-only) — end the active session early and re-queue. The
// hook flips status from 'active' → 'queued', which unmounts <Chat> and
// mounts <WaitingRoomScreen>, where the user can pick a different model.
defineCommand({
name: 'end-session',
handler: (params) => {
params.setMessages((prev) => [
...prev,
getUserMessage(params.inputValue.trim()),
getSystemMessage(END_SESSION_MESSAGE),
])
params.saveToHistory(params.inputValue.trim())
clearInput(params)
endAndRejoinFreebuffSession().catch(() => {
// The hook surfaces poll errors via the session store; nothing to do
// here beyond letting the chat history reflect the attempt.
})
},
}),
]

export const COMMAND_REGISTRY: CommandDefinition[] = IS_FREEBUFF
Expand Down
180 changes: 180 additions & 0 deletions cli/src/components/freebuff-model-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { TextAttributes } from '@opentui/core'
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 { switchFreebuffModel } from '../hooks/use-freebuff-session'
import { useFreebuffModelStore } from '../state/freebuff-model-store'
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
import { useTheme } from '../hooks/use-theme'

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

/**
* Lets the user pick which model's queue they're in. Switching triggers a
* re-POST: the server moves them to the back of the new model's queue, which
* means switching is *not free* — they lose their place in the original line.
*
* To prevent accidental queue loss, keyboard navigation is two-step: Tab /
* arrow keys move a focus highlight, and Enter commits the switch. Mouse
* clicks are still one-step (the click target is intentional).
*
* Each row shows a live "N ahead" count sourced from the server's
* `queueDepthByModel` snapshot so the choice is informed (e.g. "3 ahead" vs
* "12 ahead") rather than a blind preference toggle.
*/
export const FreebuffModelSelector: React.FC = () => {
const theme = useTheme()
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
const session = useFreebuffSessionStore((s) => s.session)
const [pending, setPending] = useState<string | null>(null)
const [hoveredId, setHoveredId] = useState<string | null>(null)
// Keyboard cursor — separate from the actually-selected model so that
// Tab/arrow navigation can preview without committing. Re-syncs to the
// selected model whenever the selection changes (after a successful switch
// or an external selectedModel update).
const [focusedId, setFocusedId] = useState<string>(selectedModel)
useEffect(() => {
setFocusedId(selectedModel)
}, [selectedModel])

// For the user's current queue, "ahead" is `position - 1` (themselves don't
// count). For every other queue, switching would land them at the back, so
// it's that queue's full depth. Null before the first queued snapshot so
// the UI doesn't flash misleading zeros.
const aheadByModel = useMemo<Record<string, number> | null>(() => {
if (session?.status !== 'queued') return null
const depths = session.queueDepthByModel ?? {}
const out: Record<string, number> = {}
for (const { id } of FREEBUFF_MODELS) {
out[id] =
id === session.model ? Math.max(0, session.position - 1) : depths[id] ?? 0
}
return out
}, [session])

// Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so
// buttons don't visibly resize when the queue depth ticks down (12 → 9) or
// when the user's selection moves between queues. The tagline is shown
// inline with the name now, so it's no longer part of this slot.
const hintWidth = useMemo(
() => Math.max('No wait'.length, '999 ahead'.length),
[],
)

const pick = useCallback(
(modelId: string) => {
if (pending) return
if (modelId === selectedModel) return
setPending(modelId)
switchFreebuffModel(modelId).finally(() => setPending(null))
},
[pending, selectedModel],
)

// Tab / Shift+Tab and Left/Right arrow keys move the focus highlight only;
// Enter or Space commits the switch. Two-step navigation prevents the user
// from accidentally giving up their place in line by tabbing past their
// queue. Up/Down intentionally do nothing so they don't fight other
// vertical UI.
useKeyboard(
useCallback(
(key: KeyEvent) => {
if (pending) return
const name = key.name ?? ''
const isForward = name === 'right' || (name === 'tab' && !key.shift)
const isBackward = name === 'left' || (name === 'tab' && key.shift)
const isCommit = name === 'return' || name === 'enter' || name === 'space'
if (!isForward && !isBackward && !isCommit) return
if (isCommit) {
if (focusedId !== selectedModel) {
key.preventDefault?.()
pick(focusedId)
}
return
}
const currentIdx = FREEBUFF_MODELS.findIndex((m) => m.id === focusedId)
if (currentIdx === -1) return
const len = FREEBUFF_MODELS.length
const nextIdx = isForward
? (currentIdx + 1) % len
: (currentIdx - 1 + len) % len
const target = FREEBUFF_MODELS[nextIdx]
if (target) {
key.preventDefault?.()
setFocusedId(target.id)
}
},
[pending, pick, focusedId, selectedModel],
),
)

return (
<box
style={{
flexDirection: 'column',
alignItems: 'flex-start',
gap: 0,
}}
>
<box
style={{
flexDirection: 'row',
gap: 2,
}}
>
{FREEBUFF_MODELS.map((model) => {
const isSelected = model.id === selectedModel
const isHovered = hoveredId === model.id
const isFocused = focusedId === model.id && !isSelected
const indicator = isSelected ? '●' : '○'
const indicatorColor = isSelected ? theme.primary : theme.muted
const labelColor = isSelected ? theme.foreground : theme.muted
const interactable = !pending && !isSelected
const ahead = aheadByModel?.[model.id]
const hint =
ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ahead} ahead`

const borderColor = isSelected
? theme.primary
: (isFocused || isHovered) && interactable
? theme.foreground
: theme.border

return (
<Button
key={model.id}
onClick={() => {
setFocusedId(model.id)
pick(model.id)
}}
onMouseOver={() => interactable && setHoveredId(model.id)}
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
style={{
borderStyle: 'single',
borderColor,
paddingLeft: 1,
paddingRight: 1,
}}
border={['top', 'bottom', 'left', 'right']}
>
<text>
<span fg={indicatorColor}>{indicator} </span>
<span
fg={labelColor}
attributes={isSelected ? TextAttributes.BOLD : TextAttributes.NONE}
>
{model.displayName}
</span>
<span fg={theme.muted}> · {model.tagline}</span>
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
</text>
</Button>
)
})}
</box>
</box>
)
}
47 changes: 44 additions & 3 deletions cli/src/components/status-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
import { getFreebuffModel } from '@codebuff/common/constants/freebuff-models'
import { TextAttributes } from '@opentui/core'
import React, { useEffect, useState } from 'react'

import { Button } from './button'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { ShimmerText } from './shimmer-text'
import { StopButton } from './stop-button'

import { useFreebuffSessionProgress } from '../hooks/use-freebuff-session-progress'
import { useTheme } from '../hooks/use-theme'
import { formatElapsedTime } from '../utils/format-elapsed-time'

import type { FreebuffSessionResponse } from '../types/freebuff-session'
import type { StatusIndicatorState } from '../utils/status-indicator-state'

/** A small status-bar action button with hover-bold styling. */
const StatusActionButton = ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => {
const theme = useTheme()
const [hovered, setHovered] = useState(false)

return (
<Button
style={{ paddingLeft: 1, paddingRight: 1 }}
onClick={onClick}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<text>
<span
fg={theme.secondary}
attributes={hovered ? TextAttributes.BOLD : TextAttributes.NONE}
>
{children}
</span>
</text>
</Button>
)
}

const SHIMMER_INTERVAL_MS = 160

Expand Down Expand Up @@ -41,6 +72,7 @@ interface StatusBarProps {
scrollToLatest: () => void
statusIndicatorState: StatusIndicatorState
onStop?: () => void
onEndSession?: () => void
freebuffSession: FreebuffSessionResponse | null
}

Expand All @@ -50,6 +82,7 @@ export const StatusBar = ({
scrollToLatest,
statusIndicatorState,
onStop,
onEndSession,
freebuffSession,
}: StatusBarProps) => {
const theme = useTheme()
Expand Down Expand Up @@ -143,9 +176,14 @@ export const StatusBar = ({
case 'idle':
if (sessionProgress !== null) {
const isUrgent = sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS
const modelName =
freebuffSession?.status === 'active'
? getFreebuffModel(freebuffSession.model).displayName
: null
return (
<span fg={isUrgent ? theme.warning : theme.secondary}>
Free session · {formatSessionRemaining(sessionProgress.remainingMs)}
{modelName ? `${modelName} · ` : ''}Free session ·{' '}
{formatSessionRemaining(sessionProgress.remainingMs)}
</span>
)
}
Expand Down Expand Up @@ -223,7 +261,10 @@ export const StatusBar = ({
>
<text style={{ wrapMode: 'none' }}>{elapsedTimeContent}</text>
{onStop && (statusIndicatorState.kind === 'waiting' || statusIndicatorState.kind === 'streaming') && (
<StopButton onClick={onStop} />
<StatusActionButton onClick={onStop}>■ Esc</StatusActionButton>
)}
{onEndSession && statusIndicatorState.kind === 'idle' && freebuffSession?.status === 'active' && (
<StatusActionButton onClick={onEndSession}>✕ End session</StatusActionButton>
)}
{sessionProgress !== null &&
sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS &&
Expand Down
32 changes: 0 additions & 32 deletions cli/src/components/stop-button.tsx

This file was deleted.

Loading
Loading