Skip to content
Open
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
33 changes: 33 additions & 0 deletions cli/src/__tests__/cli-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,37 @@ describe('Freebuff CLI Argument Parsing', () => {
expect(result.command).toBe('login')
expect(result.initialPrompt).toBeNull()
})

test('accepts -c as continue without a conversation id', () => {
const result = parseArgs({
argv: ['node', 'freebuff', '-c'],
isFreebuff: true,
version: '1.0.0',
})

expect(result.continue).toBe(true)
expect(result.continueId).toBeNull()
})

test('accepts --continue as continue without a conversation id', () => {
const result = parseArgs({
argv: ['node', 'freebuff', '--continue'],
isFreebuff: true,
version: '1.0.0',
})

expect(result.continue).toBe(true)
expect(result.continueId).toBeNull()
})

test('accepts -c with an explicit conversation id', () => {
const result = parseArgs({
argv: ['node', 'freebuff', '-c', 'abc-123'],
isFreebuff: true,
version: '1.0.0',
})

expect(result.continue).toBe(true)
expect(result.continueId).toBe('abc-123')
})
})
9 changes: 9 additions & 0 deletions cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ interface AppProps {
hasInvalidCredentials: boolean
fileTree: FileTreeNode[]
continueChat: boolean
/** Raw `-c` / `--continue` flag from the CLI, before history-pick resume
* folds in. Lets the landing screen distinguish "I asked to continue a
* session" from "I resumed a historical conversation". */
continueRequested?: boolean
continueChatId?: string
initialMode?: AgentMode
showProjectPicker: boolean
Expand All @@ -47,6 +51,7 @@ export const App = ({
hasInvalidCredentials,
fileTree,
continueChat,
continueRequested,
continueChatId,
initialMode,
showProjectPicker,
Expand Down Expand Up @@ -260,6 +265,7 @@ export const App = ({
logoutMutation={logoutMutation}
continueChat={effectiveContinueChat}
continueChatId={effectiveContinueChatId}
continueRequested={continueRequested === true}
authStatus={authStatus}
initialMode={initialMode}
gitRoot={gitRoot}
Expand All @@ -285,6 +291,7 @@ interface AuthedSurfaceProps {
logoutMutation: ReturnType<typeof useAuthState>['logoutMutation']
continueChat: boolean
continueChatId: string | undefined
continueRequested: boolean
authStatus: AuthStatus
initialMode: AgentMode | undefined
gitRoot: string | null | undefined
Expand Down Expand Up @@ -338,6 +345,7 @@ const AuthedSurfaceRoutes = ({
setUser,
logoutMutation,
authStatus,
continueRequested,
initialMode,
gitRoot,
onSwitchToGitRoot,
Expand Down Expand Up @@ -400,6 +408,7 @@ const AuthedSurfaceRoutes = ({
failure={sessionFailure}
lastRefund={lastRefund}
refundPending={refundPending}
continueRequested={continueRequested}
/>
)
}
Expand Down
4 changes: 2 additions & 2 deletions cli/src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function parseArgs({
.description('Freebuff - Free AI coding assistant')
.version(version, '-v, --version', 'Print the CLI version')
.option(
'--continue [conversation-id]',
'-c, --continue [conversation-id]',
'Continue from a previous conversation (optionally specify a conversation id)',
)
.option(
Expand All @@ -80,7 +80,7 @@ export function parseArgs({
'Remove any existing CLI log files before starting',
)
.option(
'--continue [conversation-id]',
'-c, --continue [conversation-id]',
'Continue from a previous conversation (optionally specify a conversation id)',
)
.option(
Expand Down
102 changes: 102 additions & 0 deletions cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
import { createTestRenderer } from '@opentui/core/testing'
import { createRoot, flushSync } from '@opentui/react'
import React from 'react'

import {
FreebuffNothingToContinueNotice,
NOTHING_TO_CONTINUE_MESSAGE,
shouldShowContinueNotice,
} from '../freebuff-landing-screen'
import { initializeThemeStore } from '../../hooks/use-theme'

import type { FreebuffSessionResponse } from '../../types/freebuff-session'

const ACTIVE_SESSION = {
status: 'active',
accessTier: 'full',
instanceId: 'i-1',
model: 'model',
admittedAt: '2026-01-01T00:00:00Z',
expiresAt: '2026-01-01T01:00:00Z',
remainingMs: 3_600_000,
} satisfies FreebuffSessionResponse

let cleanupRenderer: (() => void) | undefined

beforeAll(() => {
initializeThemeStore()
})

afterEach(() => {
cleanupRenderer?.()
cleanupRenderer = undefined
})

const renderNotice = async () => {
const setup = await createTestRenderer({ width: 100, height: 3 })
const root = createRoot(setup.renderer)
cleanupRenderer = () => {
flushSync(() => root.unmount())
setup.renderer.destroy()
}
flushSync(() => root.render(<FreebuffNothingToContinueNotice />))
await setup.renderOnce()
return setup
}

describe('FreebuffNothingToContinueNotice', () => {
test('tells the user there is nothing to continue', async () => {
const setup = await renderNotice()
const frame = setup.captureCharFrame().replace(/\s+/g, ' ')

expect(frame).toContain('nothing to continue')
expect(frame).toContain('no active session was found')
})

test('mentions starting a new session as the way forward', async () => {
const setup = await renderNotice()

expect(setup.captureCharFrame()).toContain('Pick a model below to start')
})

test('wraps the message on a narrow terminal rather than clipping it', async () => {
const setup = await createTestRenderer({ width: 40, height: 4 })
const root = createRoot(setup.renderer)
cleanupRenderer = () => {
flushSync(() => root.unmount())
setup.renderer.destroy()
}
flushSync(() => root.render(<FreebuffNothingToContinueNotice />))
await setup.renderOnce()

const frame = setup.captureCharFrame().replace(/\s+/g, ' ')
expect(frame).toContain(NOTHING_TO_CONTINUE_MESSAGE.replace(/\s+/g, ' '))
})
})

describe('shouldShowContinueNotice', () => {
test('only ever shows on a `status: none` session', () => {
expect(shouldShowContinueNotice(true, { status: 'none' })).toBe(true)

const resumed: FreebuffSessionResponse[] = [
ACTIVE_SESSION,
{ status: 'takeover_prompt', model: 'model' },
{ status: 'ended', freebucksRefund: 4 },
{ status: 'superseded' },
{ status: 'consent_required', walletConsent: { price: 1, walletSpend: 1 }, freebucks: null },
]
for (const session of resumed) {
expect(shouldShowContinueNotice(true, session)).toBe(false)
}
})

test('still probing (null session) never shows the notice', () => {
expect(shouldShowContinueNotice(true, null)).toBe(false)
})

test('without the `-c` / `--continue` flag nothing renders', () => {
expect(shouldShowContinueNotice(false, { status: 'none' })).toBe(false)
expect(shouldShowContinueNotice(false, ACTIVE_SESSION)).toBe(false)
})
})
49 changes: 48 additions & 1 deletion cli/src/components/freebuff-landing-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ interface FreebuffLandingScreenProps {
failure: FreebuffSessionFailure | null
lastRefund: number | null
refundPending: boolean
/** True when the CLI was launched with `-c` / `--continue`. Drives the
* "nothing to continue" notice: only a session resume can satisfy the
* user's ask, so the landing screen has to say it found nothing rather
* than behaving like a plain first launch. */
continueRequested?: boolean
}

/** Landing-screen heading. Referenced both as rendered text and by the
Expand All @@ -77,6 +82,33 @@ interface FreebuffLandingScreenProps {
const LANDING_HEADING = 'Start coding for free'
const COLLAPSED_LOGO_MIN_HEIGHT = 26

/** Shown on the landing screen when the user launched with `-c` /
* `--continue` but the probe found no active session to resume (no seat,
* expired, or explicitly ended). Exported so the render test can mount it
* without the landing screen's ad/streak/logo machinery. */
export const NOTHING_TO_CONTINUE_MESSAGE =
"There's nothing to continue — no active session was found. Pick a model below to start a new one."

export const FreebuffNothingToContinueNotice: React.FC = () => {
const theme = useTheme()
return (
<text style={{ fg: theme.secondary, wrapMode: 'word', marginTop: 1 }}>
{NOTHING_TO_CONTINUE_MESSAGE}
</text>
)
}

/** The notice must only ever appear when the user asked to resume AND no
* session is actually resumed. A successful resume either lands outside this
* screen entirely or surfaces a `status: 'active'`/takeover session, never a
* `status: 'none'` one, so gating on that status is what makes
* `isLanding` mean "nothing was resumed". Exported so the invariant is
* testable without mounting the whole landing screen. */
export const shouldShowContinueNotice = (
continueRequested: boolean,
session: FreebuffSessionResponse | null,
): boolean => continueRequested && session?.status === 'none'

/** "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 => {
Expand Down Expand Up @@ -358,6 +390,7 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
failure,
lastRefund,
refundPending,
continueRequested,
}) => {
const theme = useTheme()
const renderer = useRenderer()
Expand Down Expand Up @@ -593,9 +626,20 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
// scrollbox is measured by the selector itself and must NOT be reserved
// here as well, or the viewport shrinks while the content grows.
const belowPickerRows = streakRows + noticeRows + streakBonusRows
// The continue notice renders above the picker, so its rows must be carved
// out of the picker's viewport budget the way the heading's are.
const continueNoticeRows = shouldShowContinueNotice(
continueRequested === true,
session,
)
? 1 /* marginTop */ + wrappedRows(NOTHING_TO_CONTINUE_MESSAGE)
: 0
const reservedChrome = 2 + adRows + 1 /* main paddingBottom */ + logoBlockRows
const landingTextRows =
wrappedRows(LANDING_HEADING) + textMarginBottom + belowPickerRows
wrappedRows(LANDING_HEADING) +
textMarginBottom +
continueNoticeRows +
belowPickerRows
// Floor = one whole recommended card: 2 border rows + its 2 text lines (name
// + tagline, then the AI-training warning on its own line). Rows grew from
// one text line to two when the warning stopped inlining, so the old floor of
Expand Down Expand Up @@ -726,6 +770,9 @@ export const FreebuffLandingScreen: React.FC<FreebuffLandingScreenProps> = ({
onDismiss={freebucksIntro.dismiss}
/>
)}
{shouldShowContinueNotice(continueRequested === true, session) && (
<FreebuffNothingToContinueNotice />
)}
<LandingHeadingRow
streakLine={streakOnHeadingRow ? streakLine : null}
marginBottom={textMarginBottom}
Expand Down
5 changes: 5 additions & 0 deletions cli/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,11 @@ async function main(): Promise<void> {
hasInvalidCredentials={hasInvalidCredentials}
fileTree={fileTree}
continueChat={continueChat}
// continueRequested is the raw CLI `-c` / `--continue` flag. It feeds
// the landing screen's "nothing to continue" notice and can diverge
// from continueChat once app.tsx folds in a history-pick resume
// (effectiveContinueChat = continueChat || resumeChatId !== null).
continueRequested={continueChat}
continueChatId={continueId ?? undefined}
initialMode={initialMode}
showProjectPicker={showProjectPickerScreen}
Expand Down
Loading