Skip to content

Commit abd1eed

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@7efb0a7c27f171e704f1d43c0488f905b0d6c7d1
1 parent cc78c73 commit abd1eed

6 files changed

Lines changed: 386 additions & 79 deletions

File tree

bun.lock

Lines changed: 52 additions & 46 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import fs from 'fs'
2+
import os from 'os'
3+
import path from 'path'
4+
5+
import { freebucksFixture } from '@codebuff/common/testing/freebuff'
6+
import {
7+
FREEBUFF_GLM_V53_FLASH_MODEL_ID,
8+
FALLBACK_FREEBUFF_MODEL_ID,
9+
} from '@codebuff/common/constants/freebuff-models'
10+
import { afterEach, beforeAll, expect, spyOn, test } from 'bun:test'
11+
import { createTestRenderer } from '@opentui/core/testing'
12+
import { createRoot, flushSync } from '@opentui/react'
13+
import React from 'react'
14+
15+
import * as auth from '../../utils/auth'
16+
import { FreebucksIntroCard, useFreebucksIntro } from '../freebucks-intro-card'
17+
import { FreebuffModelSelector } from '../freebuff-model-selector'
18+
import { initializeThemeStore } from '../../hooks/use-theme'
19+
import { useFreebuffModelStore } from '../../state/freebuff-model-store'
20+
import { useFreebuffSessionStore } from '../../state/freebuff-session-store'
21+
22+
/** Outside DeepSeek's expensive window and inside deployment hours, so every
23+
* catalog row is open regardless of the hour CI runs at. */
24+
const FIXED_NOW_MS = Date.UTC(2026, 7, 20, 19, 0, 0)
25+
26+
let cleanupRenderer: (() => void) | undefined
27+
let testConfigDir: string | undefined
28+
let getConfigDirSpy: ReturnType<typeof spyOn> | undefined
29+
30+
beforeAll(() => {
31+
initializeThemeStore()
32+
})
33+
34+
afterEach(() => {
35+
cleanupRenderer?.()
36+
cleanupRenderer = undefined
37+
getConfigDirSpy?.mockRestore()
38+
getConfigDirSpy = undefined
39+
if (testConfigDir) {
40+
fs.rmSync(testConfigDir, { recursive: true, force: true })
41+
testConfigDir = undefined
42+
}
43+
useFreebuffSessionStore.getState().setSession(null)
44+
useFreebuffModelStore.getState().setSelectedModel(FALLBACK_FREEBUFF_MODEL_ID)
45+
})
46+
47+
/** The landing screen's arrangement, reduced to the two pieces that share the
48+
* keyboard: the intro card above and the picker below. */
49+
const Landing = ({
50+
startSession,
51+
}: {
52+
startSession: (model: string) => Promise<void>
53+
}) => {
54+
const intro = useFreebucksIntro(true)
55+
return (
56+
<box style={{ flexDirection: 'column' }}>
57+
{intro.visible && (
58+
<FreebucksIntroCard width={72} onDismiss={intro.dismiss} />
59+
)}
60+
<FreebuffModelSelector
61+
maxHeight={40}
62+
nowMs={FIXED_NOW_MS}
63+
keyboardSuspended={intro.visible}
64+
startSession={startSession}
65+
/>
66+
</box>
67+
)
68+
}
69+
70+
const renderLanding = async (startSession: (model: string) => Promise<void>) => {
71+
testConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'freebucks-intro-'))
72+
getConfigDirSpy = spyOn(auth, 'getConfigDir').mockReturnValue(testConfigDir)
73+
useFreebuffSessionStore.getState().setSession({
74+
status: 'none',
75+
accessTier: 'full',
76+
freebucks: freebucksFixture(75),
77+
} as never)
78+
useFreebuffModelStore
79+
.getState()
80+
.setSelectedModel(FREEBUFF_GLM_V53_FLASH_MODEL_ID)
81+
82+
const setup = await createTestRenderer({ width: 100, height: 40 })
83+
const root = createRoot(setup.renderer)
84+
cleanupRenderer = () => {
85+
flushSync(() => root.unmount())
86+
setup.renderer.destroy()
87+
}
88+
flushSync(() => root.render(<Landing startSession={startSession} />))
89+
await setup.renderOnce()
90+
return setup
91+
}
92+
93+
// The bug this file exists for: the card's "press any key to continue" and the
94+
// picker's "Enter starts a session" were two independent keyboard
95+
// subscriptions, so the dismissal press ALSO committed the focused row — the
96+
// cheapest one, since a metered list is sorted by price. Users reported it as
97+
// "it started a GLM 5.3 session I didn't want to start" (2026-09-08).
98+
test('dismissing the Freebucks intro does not start a session', async () => {
99+
const requested: string[] = []
100+
const setup = await renderLanding(async (model) => {
101+
requested.push(model)
102+
})
103+
expect(setup.captureCharFrame()).toContain('Meet Freebucks')
104+
105+
flushSync(() => setup.mockInput.pressEnter())
106+
await setup.renderOnce()
107+
108+
expect(requested).toEqual([])
109+
expect(setup.captureCharFrame()).not.toContain('Meet Freebucks')
110+
111+
// And the picker is live again the moment the card is gone: the next Enter
112+
// is the one the user meant for it.
113+
flushSync(() => setup.mockInput.pressEnter())
114+
await setup.renderOnce()
115+
expect(requested).toEqual([FREEBUFF_GLM_V53_FLASH_MODEL_ID])
116+
})
117+
118+
test('space dismisses the intro without committing a row either', async () => {
119+
const requested: string[] = []
120+
const setup = await renderLanding(async (model) => {
121+
requested.push(model)
122+
})
123+
flushSync(() => setup.mockInput.pressKey(' '))
124+
await setup.renderOnce()
125+
expect(requested).toEqual([])
126+
expect(setup.captureCharFrame()).not.toContain('Meet Freebucks')
127+
})

cli/src/components/__tests__/freebuff-model-selector.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { createTestRenderer } from '@opentui/core/testing'
1010
import { createRoot, flushSync } from '@opentui/react'
1111
import React from 'react'
1212

13+
import { FREEBUCKS_LABEL } from '../../utils/freebucks'
14+
import * as openUrl from '../../utils/open-url'
1315
import { FreebuffModelSelector } from '../freebuff-model-selector'
1416
import {
1517
FREEBUFF_REWARD_MODEL_ID,
@@ -918,6 +920,88 @@ test.each([
918920
},
919921
)
920922

923+
// A row the meter cannot cover used to be inert in both directions: the Enter
924+
// handler and the click handler both gated on `isJoinable`, so pressing it did
925+
// nothing at all — no message, no plans link. On a metered account that is the
926+
// whole of what users reported as "I can't change models": the cheapest row
927+
// starts and every dearer one is silent (2026-09-08). The wall now speaks, and
928+
// the second press opens the page that is the only thing which changes the
929+
// answer.
930+
describe('a row the balance cannot cover', () => {
931+
const renderUnaffordableLuna = async () => {
932+
useFreebuffSessionStore.getState().setSession({
933+
status: 'none',
934+
accessTier: 'full',
935+
freebucks: freebucksFixture(10, {
936+
[FREEBUFF_GPT_5_6_LUNA_MODEL_ID]: 20,
937+
[FREEBUFF_MIMO_V25_MODEL_ID]: 10,
938+
}),
939+
})
940+
useFreebuffModelStore
941+
.getState()
942+
.setSelectedModel(FREEBUFF_MIMO_V25_MODEL_ID)
943+
const requested: string[] = []
944+
const setup = await renderSelector(40, async (model) => {
945+
requested.push(model)
946+
})
947+
await setup.renderOnce()
948+
// Walk the focus onto Luna rather than assuming where it lands.
949+
for (let i = 0; i < 12; i++) {
950+
if (setup.captureCharFrame().includes('› GPT-5.6 Luna')) break
951+
flushSync(() => setup.mockInput.pressKey('ARROW_DOWN'))
952+
await setup.renderOnce()
953+
}
954+
expect(setup.captureCharFrame()).toContain('› GPT-5.6 Luna')
955+
return { setup, requested }
956+
}
957+
958+
test('explains the wall on the first press instead of doing nothing', async () => {
959+
const { setup, requested } = await renderUnaffordableLuna()
960+
flushSync(() => setup.mockInput.pressEnter())
961+
await setup.renderOnce()
962+
const frame = setup.captureCharFrame()
963+
expect(frame).toContain(`Not enough ${FREEBUCKS_LABEL}`)
964+
expect(frame).toContain('Enter opens plans')
965+
expect(requested).toEqual([])
966+
})
967+
968+
test('opens the plans page on the second press, and starts nothing', async () => {
969+
const openSpy = spyOn(openUrl, 'safeOpen').mockResolvedValue(true)
970+
try {
971+
const { setup, requested } = await renderUnaffordableLuna()
972+
flushSync(() => setup.mockInput.pressEnter())
973+
await setup.renderOnce()
974+
flushSync(() => setup.mockInput.pressEnter())
975+
await setup.renderOnce()
976+
expect(openSpy).toHaveBeenCalledWith('https://freebuff.com/plans')
977+
expect(requested).toEqual([])
978+
expect(getSelectedFreebuffModel()).toBe(FREEBUFF_MIMO_V25_MODEL_ID)
979+
} finally {
980+
openSpy.mockRestore()
981+
}
982+
})
983+
984+
test('a row closed for the hour stays inert — no wall to raise', async () => {
985+
useFreebuffSessionStore.getState().setSession({
986+
status: 'none',
987+
accessTier: 'full',
988+
freebucks: freebucksFixture(10, {
989+
[FREEBUFF_GPT_5_6_LUNA_MODEL_ID]: 20,
990+
}),
991+
})
992+
useFreebuffModelStore
993+
.getState()
994+
.setSelectedModel(FREEBUFF_MIMO_V25_MODEL_ID)
995+
const setup = await renderSelector()
996+
await setup.renderOnce()
997+
// Nothing is asking anything before a press; the assertion above is what
998+
// makes the two cases distinguishable at all.
999+
expect(setup.captureCharFrame()).not.toContain(
1000+
`Not enough ${FREEBUCKS_LABEL}`,
1001+
)
1002+
})
1003+
})
1004+
9211005
test.each([false, true])(
9221006
'Freebucks does not change Luna plan access (paid=%s)',
9231007
async (paid) => {

cli/src/components/freebucks-intro-card.tsx

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,74 @@
11
import { TextAttributes } from '@opentui/core'
22
import { useKeyboard } from '@opentui/react'
3-
import { useEffect, useState } from 'react'
3+
import { useCallback, useEffect, useState } from 'react'
44

55
import { FREEBUCKS_INTRO } from '../utils/freebucks'
66
import { hasSeenFreebucksIntro, markFreebucksIntroSeen } from '../utils/settings'
77
import { useTheme } from '../hooks/use-theme'
88

99
/**
10-
* The one-time introduction to Freebucks, above the picker on the landing.
10+
* Whether the one-time Freebucks introduction is on screen, and how to retire it.
1111
*
1212
* Shown the FIRST launch on which the account is METERED — the caller passes
1313
* `metered`, read off the session's `freebucks` block like every other
14-
* surface — and never again. The seen mark is written the moment this
15-
* RENDERS, not on dismissal: a launch that ends before the user presses
16-
* anything must not be shown it twice. It is a card and not a modal because
17-
* the CLI landing has no modal layer and the picker below it keeps working;
18-
* any key the picker handles also retires the card for this launch.
14+
* surface — and never again. The seen mark is written the moment it becomes
15+
* visible, not on dismissal: a launch that ends before the user presses
16+
* anything must not be shown it twice.
17+
*
18+
* THE VISIBILITY LIVES IN THE PARENT, not inside the card, because the picker
19+
* below has to know about it. The card says "press any key to continue" and
20+
* the picker commits its focused row on Enter or Space; the two are
21+
* independent `useKeyboard` subscriptions, so the dismissal key ALSO started a
22+
* session on whichever row the cursor happened to be on — the cheapest one,
23+
* since a metered list is sorted by price. A charge the user never asked for,
24+
* on a model they did not choose, and the most reported Freebucks bug
25+
* (2026-09-08).
26+
*
27+
* The landing screen suspends the picker's keyboard while this is true, and
28+
* the card stops the dismissal key propagating. EITHER guard alone would fix
29+
* it, and neither is enough on its own to rely on: which handler runs first is
30+
* an artifact of effect registration order (children before parents, siblings
31+
* in tree order), which no one should have to reason about to know whether a
32+
* keypress spends money.
33+
*/
34+
export function useFreebucksIntro(metered: boolean): {
35+
visible: boolean
36+
dismiss: () => void
37+
} {
38+
const [visible, setVisible] = useState<boolean>(
39+
() => metered && !hasSeenFreebucksIntro(),
40+
)
41+
useEffect(() => {
42+
if (!metered) return
43+
if (hasSeenFreebucksIntro()) return
44+
markFreebucksIntroSeen()
45+
setVisible(true)
46+
}, [metered])
47+
const dismiss = useCallback(() => setVisible(false), [])
48+
return { visible, dismiss }
49+
}
50+
51+
/**
52+
* The card itself, above the picker on the landing. Rendered only while
53+
* `useFreebucksIntro` says its state is visible; it is a card and not a modal
54+
* because the CLI landing has no modal layer.
55+
*
56+
* It owns the dismissal key and consumes it — see the hook above for why that
57+
* is one of two guards rather than the only one.
1958
*/
2059
export function FreebucksIntroCard({
21-
metered,
2260
width,
61+
onDismiss,
2362
}: {
24-
metered: boolean
2563
width: number
64+
onDismiss: () => void
2665
}) {
2766
const theme = useTheme()
28-
const [show, setShow] = useState<boolean>(() => metered && !hasSeenFreebucksIntro())
29-
useEffect(() => {
30-
if (!metered) return
31-
if (hasSeenFreebucksIntro()) return
32-
markFreebucksIntroSeen()
33-
setShow(true)
34-
}, [metered])
35-
// Any key retires it for this launch; the picker still receives the key.
36-
useKeyboard(() => {
37-
if (show) setShow(false)
67+
useKeyboard((key) => {
68+
key.stopPropagation?.()
69+
key.preventDefault?.()
70+
onDismiss()
3871
})
39-
if (!show) return null
4072
return (
4173
<box
4274
style={{

cli/src/components/freebuff-landing-screen.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { Button } from './button'
1414
import { ChoiceAdBanner, AD_CARD_HEIGHT } from './ad-banner'
1515
import { visibleWaitingRoomPlacementIds } from '@codebuff/common/ads/waiting-room-placements'
16-
import { FreebucksIntroCard } from './freebucks-intro-card'
16+
import { FreebucksIntroCard, useFreebucksIntro } from './freebucks-intro-card'
1717
import { FreebuffModelSelector } from './freebuff-model-selector'
1818
import { ShimmerText } from './shimmer-text'
1919
import {
@@ -460,6 +460,16 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
460460
// state: show the picker with a prompt. Picking a model triggers
461461
// startFreebuffSession, which POSTs and transitions straight to 'active' (chat).
462462
const isLanding = session?.status === 'none'
463+
// The one-time Freebucks introduction, only where the picker itself is on
464+
// screen and only where it fits: on a short terminal it would push the
465+
// picker off the bottom, and an unseen card is shown on the next launch
466+
// instead (it is marked seen only when it becomes visible, so a launch that
467+
// lands on a wall instead of the picker must not consume it). Held here
468+
// rather than inside the card so the picker below can be told to ignore the
469+
// key that dismisses it — see `useFreebucksIntro`.
470+
const freebucksIntro = useFreebucksIntro(
471+
isLanding && terminalHeight >= 30 && freebucksOf(session) !== undefined,
472+
)
463473
// On the meter, nothing below counts sessions.
464474
const metered = freebucksOf(session) !== undefined
465475
const streakQuery = useFreebuffStreakQuery({
@@ -710,14 +720,10 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
710720
gap: 0,
711721
}}
712722
>
713-
{/* The one-time Freebucks introduction, only where it fits:
714-
on a short terminal it would push the picker off the
715-
bottom, and an unseen card is shown on the next launch
716-
instead (it is marked seen only when it renders). */}
717-
{terminalHeight >= 30 && (
723+
{freebucksIntro.visible && (
718724
<FreebucksIntroCard
719-
metered={freebucksOf(session) !== undefined}
720725
width={Math.min(contentMaxWidth, 72)}
726+
onDismiss={freebucksIntro.dismiss}
721727
/>
722728
)}
723729
<LandingHeadingRow
@@ -730,6 +736,9 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
730736
<FreebuffModelSelector
731737
maxHeight={selectorMaxHeight}
732738
onExpandedChange={setSelectorExpanded}
739+
// The intro card owns the keyboard while it is up: its "press
740+
// any key" must not also commit the focused row.
741+
keyboardSuspended={freebucksIntro.visible}
733742
belowToggle={
734743
showBelowPickerCounter ? (
735744
<text

0 commit comments

Comments
 (0)