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 @@ -179,6 +179,39 @@ describe('Freebuff CLI Argument Parsing', () => {
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')
})

test('parses --trust-agents and defaults it to false', () => {
expect(
parseArgs({
Expand Down
10 changes: 10 additions & 0 deletions cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ export const App = ({
// State to track which chat to resume (set when user selects from history)
const [resumeChatId, setResumeChatId] = useState<string | null>(null)
const [continueRequested, setContinueRequested] = useState(continueChat)
// Raw `-c` / `--continue` from the command line, captured once at launch.
// The chat's "nothing to continue" notice keys off this, not
// `continueRequested`: a history-pick resume flips that state too, but
// resuming a past conversation never satisfies a launch-time request to
// resume a live session.
const continueFlagRequested = continueChat

const handleResumeChat = useCallback(
(chatId: string) => {
Expand Down Expand Up @@ -262,6 +268,7 @@ export const App = ({
logoutMutation={logoutMutation}
continueChat={effectiveContinueChat}
continueChatId={effectiveContinueChatId}
continueRequested={continueFlagRequested}
authStatus={authStatus}
initialMode={initialMode}
gitRoot={gitRoot}
Expand All @@ -287,6 +294,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 @@ -333,6 +341,7 @@ export const AuthedSurfaceRoutes = ({
setUser,
logoutMutation,
authStatus,
continueRequested,
initialMode,
gitRoot,
onSwitchToGitRoot,
Expand Down Expand Up @@ -369,6 +378,7 @@ export const AuthedSurfaceRoutes = ({
gitRoot={gitRoot}
onSwitchToGitRoot={onSwitchToGitRoot}
freebuffSession={session}
continueRequested={continueRequested}
/>
)
}
13 changes: 13 additions & 0 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import { ChatInputBar } from './components/chat-input-bar'
import { ChatHeader } from './components/chat-header'
import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary'
import { FreebuffNothingToContinueNotice } from './components/freebuff-nothing-to-continue-notice'
import { LoadPreviousButton } from './components/load-previous-button'
import { QueuePanel } from './components/queue-panel'
import { ReviewScreen } from './components/review-screen'
Expand Down Expand Up @@ -166,6 +167,7 @@ export const Chat = ({
gitRoot,
onSwitchToGitRoot,
freebuffSession,
continueRequested,
}: {
consumeInitialPrompt: () => string | null
fileTree: FileTreeNode[]
Expand All @@ -178,6 +180,8 @@ export const Chat = ({
gitRoot?: string | null
onSwitchToGitRoot?: () => void
freebuffSession: FreebuffSessionResponse | null
/** Raw `-c` / `--continue` flag, before history-pick resume folds in. */
continueRequested?: boolean
}) => {
const [forceFileOnlyMentions, setForceFileOnlyMentions] = useState(false)
const headerRef = useRef<BoxRenderable | null>(null)
Expand Down Expand Up @@ -2341,6 +2345,15 @@ export const Chat = ({
{freebuffAdmissionNotice(freebuffSession)}
</text>
)}
{/* Launch-time `-c` / `--continue` with no live session to resume.
Distinct from the admission notice above, which never fires on a
`status: 'none'` session. */}
{IS_FREEBUFF && (
<FreebuffNothingToContinueNotice
continueRequested={continueRequested === true}
session={freebuffSession}
/>
)}
{inputMode === 'help' && !freebuffControlsOpen && <HelpBanner />}

{shouldShowStatusLine && (
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 @@ -56,7 +56,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 Down Expand Up @@ -86,7 +86,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
136 changes: 136 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,136 @@
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-nothing-to-continue-notice'
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
continueRequested={true}
session={{ status: 'none' }}
/>,
),
)
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
continueRequested={true}
session={{ status: 'none' }}
/>,
),
)
await setup.renderOnce()

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

test('renders nothing when no continue was requested', 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
continueRequested={false}
session={{ status: 'none' }}
/>,
),
)
await setup.renderOnce()

expect(setup.captureCharFrame().trim()).toBe('')
})
})

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: 'banned' },
]
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)
})
})
44 changes: 44 additions & 0 deletions cli/src/components/freebuff-nothing-to-continue-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react'

import { useTheme } from '../hooks/use-theme'

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

/** Shown when the user launched with `-c` / `--continue` but the probe found
* no active session to resume (no seat, expired, or explicitly ended). */
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."

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

interface FreebuffNothingToContinueNoticeProps {
/** The raw `-c` / `--continue` flag, before history-pick resume folds in. */
continueRequested: boolean
session: FreebuffSessionResponse | null
}

/**
* Says the launch-time request to resume a live session found nothing, rather
* than behaving like a plain first launch. Renders nothing unless a continue
* was requested and no session was resumed.
*/
export const FreebuffNothingToContinueNotice: React.FC<
FreebuffNothingToContinueNoticeProps
> = ({ continueRequested, session }) => {
const theme = useTheme()
if (!shouldShowContinueNotice(continueRequested, session)) return null
return (
<text style={{ fg: theme.secondary, wrapMode: 'word', marginTop: 1 }}>
{NOTHING_TO_CONTINUE_MESSAGE}
</text>
)
}
Loading