From ce4724063bb8411fde0e48bd75dbba32bff02301 Mon Sep 17 00:00:00 2001 From: shauryagangrade <288927048+shauryagangrade@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:21:42 +0530 Subject: [PATCH 1/2] feat(cli): resume active freebuff session with -c/--continue Add the -c short flag for --continue and surface a clear "nothing to continue" notice on the landing screen when a session resume is requested but no active session exists. The underlying resume path (auto-takeover of a dead local process's seat plus conversation restore) already preserves the server session and its remaining time. --- cli/src/__tests__/cli-args.test.ts | 33 ++++++++++ cli/src/app.tsx | 9 +++ cli/src/cli-args.ts | 4 +- .../freebuff-nothing-to-continue.test.tsx | 63 +++++++++++++++++++ .../components/freebuff-landing-screen.tsx | 34 +++++++++- cli/src/index.tsx | 1 + 6 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx diff --git a/cli/src/__tests__/cli-args.test.ts b/cli/src/__tests__/cli-args.test.ts index 505ea7468d..0aab3697c4 100644 --- a/cli/src/__tests__/cli-args.test.ts +++ b/cli/src/__tests__/cli-args.test.ts @@ -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') + }) }) diff --git a/cli/src/app.tsx b/cli/src/app.tsx index fc31eef928..0e21edf7eb 100644 --- a/cli/src/app.tsx +++ b/cli/src/app.tsx @@ -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 @@ -47,6 +51,7 @@ export const App = ({ hasInvalidCredentials, fileTree, continueChat, + continueRequested, continueChatId, initialMode, showProjectPicker, @@ -260,6 +265,7 @@ export const App = ({ logoutMutation={logoutMutation} continueChat={effectiveContinueChat} continueChatId={effectiveContinueChatId} + continueRequested={continueRequested === true} authStatus={authStatus} initialMode={initialMode} gitRoot={gitRoot} @@ -285,6 +291,7 @@ interface AuthedSurfaceProps { logoutMutation: ReturnType['logoutMutation'] continueChat: boolean continueChatId: string | undefined + continueRequested: boolean authStatus: AuthStatus initialMode: AgentMode | undefined gitRoot: string | null | undefined @@ -338,6 +345,7 @@ const AuthedSurfaceRoutes = ({ setUser, logoutMutation, authStatus, + continueRequested, initialMode, gitRoot, onSwitchToGitRoot, @@ -400,6 +408,7 @@ const AuthedSurfaceRoutes = ({ failure={sessionFailure} lastRefund={lastRefund} refundPending={refundPending} + continueRequested={continueRequested} /> ) } diff --git a/cli/src/cli-args.ts b/cli/src/cli-args.ts index 6694401b75..6c9339aa63 100644 --- a/cli/src/cli-args.ts +++ b/cli/src/cli-args.ts @@ -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( @@ -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( diff --git a/cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx b/cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx new file mode 100644 index 0000000000..0128ba4aab --- /dev/null +++ b/cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx @@ -0,0 +1,63 @@ +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, +} from '../freebuff-landing-screen' +import { initializeThemeStore } from '../../hooks/use-theme' + +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()) + 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()) + await setup.renderOnce() + + const frame = setup.captureCharFrame().replace(/\s+/g, ' ') + expect(frame).toContain(NOTHING_TO_CONTINUE_MESSAGE.replace(/\s+/g, ' ')) + }) +}) diff --git a/cli/src/components/freebuff-landing-screen.tsx b/cli/src/components/freebuff-landing-screen.tsx index 7fbca062ee..740779f434 100644 --- a/cli/src/components/freebuff-landing-screen.tsx +++ b/cli/src/components/freebuff-landing-screen.tsx @@ -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 @@ -77,6 +82,22 @@ 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 ( + + {NOTHING_TO_CONTINUE_MESSAGE} + + ) +} + /** "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 => { @@ -358,6 +379,7 @@ export const FreebuffLandingScreen: React.FC = ({ failure, lastRefund, refundPending, + continueRequested, }) => { const theme = useTheme() const renderer = useRenderer() @@ -593,9 +615,18 @@ export const FreebuffLandingScreen: React.FC = ({ // 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 = + continueRequested && isLanding + ? 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 @@ -726,6 +757,7 @@ export const FreebuffLandingScreen: React.FC = ({ onDismiss={freebucksIntro.dismiss} /> )} + {continueRequested && } { hasInvalidCredentials={hasInvalidCredentials} fileTree={fileTree} continueChat={continueChat} + continueRequested={continueChat} continueChatId={continueId ?? undefined} initialMode={initialMode} showProjectPicker={showProjectPickerScreen} From 390ac98e82f73d8f9cf35c70eeba8ef82be07065 Mon Sep 17 00:00:00 2001 From: shauryagangrade <288927048+shauryagangrade@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:33:29 +0530 Subject: [PATCH 2/2] feat(cli): gate continue notice on resumed session and pin invariant in tests --- .../freebuff-nothing-to-continue.test.tsx | 39 +++++++++++++++++++ .../components/freebuff-landing-screen.tsx | 25 +++++++++--- cli/src/index.tsx | 4 ++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx b/cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx index 0128ba4aab..68185b646b 100644 --- a/cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx +++ b/cli/src/components/__tests__/freebuff-nothing-to-continue.test.tsx @@ -6,9 +6,22 @@ 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(() => { @@ -61,3 +74,29 @@ describe('FreebuffNothingToContinueNotice', () => { 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) + }) +}) diff --git a/cli/src/components/freebuff-landing-screen.tsx b/cli/src/components/freebuff-landing-screen.tsx index 740779f434..114ae56470 100644 --- a/cli/src/components/freebuff-landing-screen.tsx +++ b/cli/src/components/freebuff-landing-screen.tsx @@ -98,6 +98,17 @@ export const FreebuffNothingToContinueNotice: React.FC = () => { ) } +/** 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 => { @@ -617,10 +628,12 @@ export const FreebuffLandingScreen: React.FC = ({ 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 = - continueRequested && isLanding - ? 1 /* marginTop */ + wrappedRows(NOTHING_TO_CONTINUE_MESSAGE) - : 0 + 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) + @@ -757,7 +770,9 @@ export const FreebuffLandingScreen: React.FC = ({ onDismiss={freebucksIntro.dismiss} /> )} - {continueRequested && } + {shouldShowContinueNotice(continueRequested === true, session) && ( + + )} { 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}