Skip to content

Commit 6c846b9

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@7341c0549f4d8062b312ea0660a49d53f96724ea
1 parent c76c8e6 commit 6c846b9

12 files changed

Lines changed: 893 additions & 102 deletions

bun.lock

Lines changed: 49 additions & 53 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,8 @@ describe('GLM selection uses the applicable meter', () => {
774774
: DEFAULT_FREEBUFF_MODEL_ID,
775775
)
776776
if (balance >= 5)
777-
expect(setup.captureCharFrame()).toContain('5 Freebucks')
777+
// The price reads `5/hr`; the balance lives in the header line.
778+
expect(setup.captureCharFrame()).toContain('5/hr')
778779
},
779780
)
780781

@@ -982,7 +983,7 @@ test('a funded Luna row does not show its exhausted legacy quota in the section
982983
useFreebuffModelStore.getState().setSelectedModel(id)
983984
const setup = await renderSelector()
984985
await setup.renderOnce()
985-
expect(setup.captureCharFrame()).toContain('20 Freebucks')
986+
expect(setup.captureCharFrame()).toContain('20/hr')
986987
expect(setup.captureCharFrame()).not.toContain('1 of 1 used')
987988
expect(getSelectedFreebuffModel()).toBe(id)
988989
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { useKeyboard } from '@opentui/react'
3+
import { useEffect, useState } from 'react'
4+
5+
import { FREEBUCKS_INTRO } from '../utils/freebucks'
6+
import { hasSeenFreebucksIntro, markFreebucksIntroSeen } from '../utils/settings'
7+
import { useTheme } from '../hooks/use-theme'
8+
9+
/**
10+
* The one-time introduction to Freebucks, above the picker on the landing.
11+
*
12+
* Shown the FIRST launch on which the account is METERED — the caller passes
13+
* `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.
19+
*/
20+
export function FreebucksIntroCard({
21+
metered,
22+
width,
23+
}: {
24+
metered: boolean
25+
width: number
26+
}) {
27+
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)
38+
})
39+
if (!show) return null
40+
return (
41+
<box
42+
style={{
43+
flexDirection: 'column',
44+
width: Math.max(24, width),
45+
border: true,
46+
borderStyle: 'rounded',
47+
borderColor: theme.secondary,
48+
paddingLeft: 1,
49+
paddingRight: 1,
50+
marginBottom: 1,
51+
flexShrink: 0,
52+
}}
53+
>
54+
<text style={{ fg: theme.foreground }} attributes={TextAttributes.BOLD}>
55+
{FREEBUCKS_INTRO.title}
56+
</text>
57+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>{FREEBUCKS_INTRO.lead}</text>
58+
{FREEBUCKS_INTRO.points.map((point) => (
59+
<text key={point} style={{ fg: theme.foreground, wrapMode: 'word' }}>
60+
<span fg={theme.secondary}></span> {point}
61+
</text>
62+
))}
63+
<text style={{ fg: theme.muted }}>{FREEBUCKS_INTRO.dismiss}</text>
64+
</box>
65+
)
66+
}

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

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import { TextAttributes } from '@opentui/core'
22
import { useKeyboard, useRenderer } from '@opentui/react'
33
import React, { useCallback, useEffect, useRef, useState } from 'react'
44

5+
import {
6+
FREEBUCKS_LABEL,
7+
formatFreebucks,
8+
freebucksOf,
9+
freebucksPriceLabel,
10+
} from '../utils/freebucks'
511
import { Button } from './button'
612
import { ChoiceAdBanner, AD_CARD_HEIGHT } from './ad-banner'
713
import { visibleWaitingRoomPlacementIds } from '@codebuff/common/ads/waiting-room-placements'
14+
import { FreebucksIntroCard } from './freebucks-intro-card'
815
import { FreebuffModelSelector } from './freebuff-model-selector'
916
import { ShimmerText } from './shimmer-text'
1017
import {
@@ -674,6 +681,16 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
674681
gap: 0,
675682
}}
676683
>
684+
{/* The one-time Freebucks introduction, only where it fits:
685+
on a short terminal it would push the picker off the
686+
bottom, and an unseen card is shown on the next launch
687+
instead (it is marked seen only when it renders). */}
688+
{terminalHeight >= 30 && (
689+
<FreebucksIntroCard
690+
metered={freebucksOf(session) !== undefined}
691+
width={Math.min(contentMaxWidth, 72)}
692+
/>
693+
)}
677694
<LandingHeadingRow
678695
streakLine={streakOnHeadingRow ? streakLine : null}
679696
marginBottom={textMarginBottom}
@@ -795,26 +812,59 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
795812
{session?.status === 'rate_limited' && (
796813
<>
797814
<text style={{ fg: theme.secondary, marginBottom: 1 }}>
798-
⚠ Session limit reached
799-
</text>
800-
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
801-
You've used{' '}
802-
<span fg={theme.foreground}>
803-
{formatSessionUnits(session.recentCount)} of {session.limit}
804-
</span>{' '}
805-
sessions{' '}
806-
{session.period === 'pacific_month'
807-
? 'this month'
808-
: session.period === 'pacific_week'
809-
? 'this week'
810-
: 'today'}
811-
. Try
812-
again in{' '}
813-
<span fg={theme.foreground}>
814-
{formatRetryAfter(session.retryAfterMs)}
815-
</span>
816-
. Press Ctrl+C to exit.
815+
{session.freebucksShortfall
816+
? `⚠ Not enough ${FREEBUCKS_LABEL}`
817+
: '⚠ Session limit reached'}
817818
</text>
819+
{/* THE METER'S REFUSAL, when the server sent one.
820+
821+
`freebucksShortfall` is what distinguishes "you are out of
822+
Freebucks" from "you have used your sessions", and the two
823+
want completely different sentences: the counts on this
824+
response are shaped like a session pool for older clients,
825+
so rendering them here would tell a metered user they had
826+
spent N of M sessions — a meter they are no longer on.
827+
828+
The monthly wall arrives in the SAME shape with no shortfall
829+
attached and `period: 'pacific_month'`, which is why the
830+
period copy below still has to be right: telling somebody to
831+
come back at midnight for an allowance that returns on the
832+
1st sends them back to the same wall in the morning. */}
833+
{session.freebucksShortfall ? (
834+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
835+
This model costs{' '}
836+
<span fg={theme.foreground}>
837+
{freebucksPriceLabel(session.freebucksShortfall.price)}
838+
</span>
839+
, and you have{' '}
840+
<span fg={theme.foreground}>
841+
{formatFreebucks(session.freebucksShortfall.balance)}
842+
</span>{' '}
843+
left. More in{' '}
844+
<span fg={theme.foreground}>
845+
{formatRetryAfter(session.retryAfterMs)}
846+
</span>
847+
, or pick a cheaper model. Press Ctrl+C to exit.
848+
</text>
849+
) : (
850+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
851+
You've used{' '}
852+
<span fg={theme.foreground}>
853+
{formatSessionUnits(session.recentCount)} of {session.limit}
854+
</span>{' '}
855+
{session.period === 'pacific_month' ? 'this month' : 'sessions'}{' '}
856+
{session.period === 'pacific_month'
857+
? ''
858+
: session.period === 'pacific_week'
859+
? 'this week'
860+
: 'today'}
861+
. Try again in{' '}
862+
<span fg={theme.foreground}>
863+
{formatRetryAfter(session.retryAfterMs)}
864+
</span>
865+
. Press Ctrl+C to exit.
866+
</text>
867+
)}
818868
{/* The paywall half (2026-09-01): the server's structured hint
819869
when it sent one, the plans page otherwise — a quota wall is
820870
never a dead end. */}

0 commit comments

Comments
 (0)