Skip to content

Commit 53022f9

Browse files
committed
feat(cli): Add login screen with browser-based authentication
Implements a new login flow for the CLI: - Login screen component with animated logo and URL display - Browser-based OAuth authentication with polling - Terminal link component for clickable URLs - Auth utilities for credential management - Analytics integration for tracking events - Clipboard utilities for URL copying - Console suppression for cleaner output The login screen integrates seamlessly into the chat interface and handles authentication before allowing CLI usage.
1 parent e7aae06 commit 53022f9

File tree

16 files changed

+1695
-173
lines changed

16 files changed

+1695
-173
lines changed

bun.lock

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

cli/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@
3737
"@opentui/react": "^0.1.28",
3838
"commander": "^14.0.1",
3939
"immer": "^10.1.3",
40+
"open": "^10.1.0",
41+
"pino": "9.4.0",
42+
"posthog-node": "4.17.2",
4043
"react": "^19.0.0",
4144
"react-reconciler": "^0.32.0",
4245
"remark-parse": "^11.0.0",
4346
"unified": "^11.0.0",
4447
"yoga-layout": "^3.2.1",
48+
"zod": "^3.24.1",
4549
"zustand": "^5.0.8"
4650
},
4751
"devDependencies": {

cli/src/chat.tsx

Lines changed: 121 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useRenderer } from '@opentui/react'
2-
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
2+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
33
import { useShallow } from 'zustand/react/shallow'
44

55
import { AgentModeToggle } from './components/agent-mode-toggle'
6+
import { LoginScreen } from './components/login-screen'
67
import { MultilineInput } from './components/multiline-input'
78
import { Separator } from './components/separator'
89
import { StatusIndicator, useHasStatus } from './components/status-indicator'
@@ -25,6 +26,9 @@ import { logger } from './utils/logger'
2526
import { buildMessageTree } from './utils/message-tree-utils'
2627
import { chatThemes, createMarkdownPalette } from './utils/theme-system'
2728

29+
import type { User } from './utils/auth'
30+
import { logoutUser } from './utils/auth'
31+
2832
import type { ToolName } from '@codebuff/sdk'
2933
import type { InputRenderable, ScrollBoxRenderable } from '@opentui/core'
3034

@@ -78,7 +82,14 @@ export type ChatMessage = {
7882
export const App = ({
7983
initialPrompt,
8084
agentId,
81-
}: { initialPrompt?: string; agentId?: string } = {}) => {
85+
requireAuth,
86+
hasInvalidCredentials,
87+
}: {
88+
initialPrompt: string | null
89+
agentId?: string
90+
requireAuth: boolean | null
91+
hasInvalidCredentials: boolean | null
92+
}) => {
8293
const renderer = useRenderer()
8394
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
8495
const inputRef = useRef<InputRenderable | null>(null)
@@ -87,6 +98,30 @@ export const App = ({
8798
const theme = chatThemes[themeName]
8899
const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme])
89100

101+
// Track authentication state
102+
const [isAuthenticated, setIsAuthenticated] = useState(!requireAuth)
103+
const [user, setUser] = useState<User | null>(null)
104+
105+
// Log app initialization
106+
useEffect(() => {
107+
logger.debug(
108+
{
109+
requireAuth,
110+
hasInvalidCredentials,
111+
hasInitialPrompt: !!initialPrompt,
112+
agentId,
113+
},
114+
'Chat App component mounted',
115+
)
116+
}, [])
117+
118+
// Handle successful login
119+
const handleLoginSuccess = useCallback((loggedInUser: User) => {
120+
setUser(loggedInUser)
121+
setIsAuthenticated(true)
122+
logger.info({ user: loggedInUser.name }, 'User logged in successfully')
123+
}, [])
124+
90125
const {
91126
inputValue,
92127
setInputValue,
@@ -471,6 +506,44 @@ export const App = ({
471506
const trimmed = inputValue.trim()
472507
if (!trimmed) return
473508

509+
const normalized = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed
510+
const cmd = normalized.split(/\s+/)[0].toLowerCase()
511+
if (cmd === 'login' || cmd === 'signin') {
512+
const msg = {
513+
id: `sys-${Date.now()}`,
514+
variant: 'ai' as const,
515+
content: "You're already in the app. Use /logout to switch accounts.",
516+
timestamp: new Date().toISOString(),
517+
}
518+
setMessages((prev) => [...prev, msg])
519+
setInputValue('')
520+
return
521+
}
522+
if (cmd === 'logout' || cmd === 'signout') {
523+
;(async () => {
524+
try {
525+
await logoutUser()
526+
} finally {
527+
abortControllerRef.current?.abort()
528+
stopStreaming()
529+
setCanProcessQueue(false)
530+
const msg = {
531+
id: `sys-${Date.now()}`,
532+
variant: 'ai' as const,
533+
content: 'Logged out.',
534+
timestamp: new Date().toISOString(),
535+
}
536+
setMessages((prev) => [...prev, msg])
537+
setInputValue('')
538+
setTimeout(() => {
539+
setUser(null)
540+
setIsAuthenticated(false)
541+
}, 300)
542+
}
543+
})()
544+
return
545+
}
546+
474547
saveToHistory(trimmed)
475548
setInputValue('')
476549

@@ -563,6 +636,17 @@ export const App = ({
563636
</text>
564637
) : null
565638

639+
// Show login screen if not authenticated
640+
if (!isAuthenticated) {
641+
return (
642+
<LoginScreen
643+
onLoginSuccess={handleLoginSuccess}
644+
theme={theme}
645+
hasInvalidCredentials={hasInvalidCredentials}
646+
/>
647+
)
648+
}
649+
566650
return (
567651
<box
568652
style={{
@@ -629,47 +713,33 @@ export const App = ({
629713
backgroundColor: theme.panelBg,
630714
}}
631715
>
632-
<box
633-
style={{
634-
flexDirection: 'row',
635-
alignItems: 'center',
636-
justifyContent: 'space-between',
637-
width: '100%',
638-
}}
639-
>
716+
{(hasStatus || queuedMessages.length > 0) && (
640717
<box
641718
style={{
642-
flexGrow: 1,
643719
flexDirection: 'row',
644720
alignItems: 'center',
721+
width: '100%',
645722
}}
646723
>
647-
{(hasStatus || queuedMessages.length > 0) && (
648-
<text wrap={false}>
649-
<StatusIndicator
650-
isProcessing={isWaitingForResponse}
651-
theme={theme}
652-
clipboardMessage={clipboardMessage}
653-
/>
654-
{hasStatus && queuedMessages.length > 0 && ' '}
655-
{queuedMessages.length > 0 && (
656-
<span fg={theme.statusSecondary} bg={theme.inputFocusedBg}>
657-
{' '}
658-
{formatQueuedPreview(
659-
queuedMessages,
660-
Math.max(30, renderer.width - 25),
661-
)}{' '}
662-
</span>
663-
)}
664-
</text>
665-
)}
724+
<text wrap={false}>
725+
<StatusIndicator
726+
isProcessing={isWaitingForResponse}
727+
theme={theme}
728+
clipboardMessage={clipboardMessage}
729+
/>
730+
{hasStatus && queuedMessages.length > 0 && ' '}
731+
{queuedMessages.length > 0 && (
732+
<span fg={theme.statusSecondary} bg={theme.inputFocusedBg}>
733+
{' '}
734+
{formatQueuedPreview(
735+
queuedMessages,
736+
Math.max(30, renderer.width - 25),
737+
)}{' '}
738+
</span>
739+
)}
740+
</text>
666741
</box>
667-
<AgentModeToggle
668-
mode={agentMode}
669-
theme={theme}
670-
onToggle={toggleAgentMode}
671-
/>
672-
</box>
742+
)}
673743
<Separator theme={theme} width={renderer.width} />
674744
{slashContext.active && slashSuggestionItems.length > 0 ? (
675745
<SuggestionMenu
@@ -703,6 +773,21 @@ export const App = ({
703773
onKeyIntercept={handleSuggestionMenuKey}
704774
/>
705775
<Separator theme={theme} width={renderer.width} />
776+
<box
777+
style={{
778+
flexDirection: 'row',
779+
alignItems: 'center',
780+
justifyContent: 'flex-end',
781+
width: '100%',
782+
paddingTop: 1,
783+
}}
784+
>
785+
<AgentModeToggle
786+
mode={agentMode}
787+
theme={theme}
788+
onToggle={toggleAgentMode}
789+
/>
790+
</box>
706791
</box>
707792
</box>
708793
)

0 commit comments

Comments
 (0)