Skip to content

Commit 8ae1ad4

Browse files
committed
Implement Tailwind-inspired theme system with variant support
- Create theme-config.ts with variant types (transparent, modal, embedded, custom) - Add ThemeProvider and useTheme() hook for context-based theme access - Add VariantProvider for setting theme variants in component trees - Migrate all components to use useTheme() instead of theme props - Add logoColor to theme interface, automatically resolved per light/dark mode - Centralize background color resolution with applyVariantBackgrounds utility - Export mergeThemeOverrides and cloneChatTheme from theme-system - Clean up LoginModal to use modal variant with solid backgrounds - Add plugin system foundation for future theme extensibility This provides a clean, hook-based API where components call useTheme() with no arguments and automatically get the appropriate theme variant based on their parent context. Modal components wrap with VariantProvider to get solid backgrounds instead of transparent ones.
1 parent 29f098b commit 8ae1ad4

File tree

12 files changed

+636
-133
lines changed

12 files changed

+636
-133
lines changed

cli/src/chat.tsx

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { useMessageRenderer } from './hooks/use-message-renderer'
3636
import { useChatScrollbox } from './hooks/use-scroll-management'
3737
import { useSendMessage } from './hooks/use-send-message'
3838
import { useSuggestionEngine } from './hooks/use-suggestion-engine'
39+
import { useTheme, useResolvedThemeName } from './hooks/use-theme'
3940
import { useChatStore } from './state/chat-store'
4041
import { flushAnalytics } from './utils/analytics'
4142
import { getUserCredentials } from './utils/auth'
@@ -50,9 +51,7 @@ import { logger } from './utils/logger'
5051
import { buildMessageTree } from './utils/message-tree-utils'
5152
import { handleSlashCommands } from './utils/slash-commands'
5253
import {
53-
chatTheme,
5454
createMarkdownPalette,
55-
onThemeChange,
5655
resolveThemeColor,
5756
type ChatTheme,
5857
} from './utils/theme-system'
@@ -170,42 +169,9 @@ export const App = ({
170169
const terminalWidth = resolvedTerminalWidth
171170
const separatorWidth = Math.max(1, Math.floor(terminalWidth) - 2)
172171

173-
const cloneTheme = (input: ChatTheme): ChatTheme => ({
174-
...input,
175-
markdown: input.markdown
176-
? {
177-
...input.markdown,
178-
headingFg: input.markdown.headingFg
179-
? { ...input.markdown.headingFg }
180-
: undefined,
181-
}
182-
: undefined,
183-
})
184-
185-
const [theme, setTheme] = useState<ChatTheme>(() => cloneTheme(chatTheme))
186-
const [resolvedThemeName, setResolvedThemeName] = useState<'dark' | 'light'>(
187-
chatTheme.messageTextAttributes ? 'dark' : 'light',
188-
)
189-
190-
useEffect(() => {
191-
const unsubscribe = onThemeChange((updatedTheme, meta) => {
192-
const nextTheme = cloneTheme(updatedTheme)
193-
setTheme(nextTheme)
194-
setResolvedThemeName(meta.resolvedThemeName)
195-
if (process.env.CODEBUFF_THEME_DEBUG === '1') {
196-
logger.debug(
197-
{
198-
themeChange: {
199-
source: meta.source,
200-
resolvedThemeName: meta.resolvedThemeName,
201-
},
202-
},
203-
'Applied theme change in chat component',
204-
)
205-
}
206-
})
207-
return unsubscribe
208-
}, [])
172+
// Use theme hooks (transparent variant is default)
173+
const theme = useTheme()
174+
const resolvedThemeName = useResolvedThemeName()
209175

210176
const markdownPalette = useMemo(
211177
() => createMarkdownPalette(theme),
@@ -316,8 +282,6 @@ export const App = ({
316282
? theme.chromeText
317283
: theme.agentResponseCount
318284

319-
const logoColor = resolvedThemeName === 'dark' ? '#4ade80' : '#15803d'
320-
321285
const homeDir = os.homedir()
322286
const repoRoot = path.dirname(loadedAgentsData.agentsDir)
323287
const relativePath = path.relative(homeDir, repoRoot)
@@ -334,7 +298,7 @@ export const App = ({
334298
{
335299
type: 'text',
336300
content: '\n\n' + logoBlock,
337-
color: logoColor,
301+
color: theme.logoColor,
338302
},
339303
]
340304

@@ -1173,7 +1137,6 @@ export const App = ({
11731137

11741138
const statusIndicatorNode = (
11751139
<StatusIndicator
1176-
theme={theme}
11771140
clipboardMessage={clipboardMessage}
11781141
isActive={isStatusActive}
11791142
timer={mainAgentTimer}
@@ -1388,12 +1351,11 @@ export const App = ({
13881351
</text>
13891352
</box>
13901353
)}
1391-
<Separator theme={theme} width={separatorWidth} />
1354+
<Separator width={separatorWidth} />
13921355
{slashContext.active && slashSuggestionItems.length > 0 ? (
13931356
<SuggestionMenu
13941357
items={slashSuggestionItems}
13951358
selectedIndex={slashSelectedIndex}
1396-
theme={theme}
13971359
maxVisible={5}
13981360
prefix="/"
13991361
/>
@@ -1404,7 +1366,6 @@ export const App = ({
14041366
<SuggestionMenu
14051367
items={agentSuggestionItems}
14061368
selectedIndex={agentSelectedIndex}
1407-
theme={theme}
14081369
maxVisible={5}
14091370
prefix="@"
14101371
/>
@@ -1424,7 +1385,6 @@ export const App = ({
14241385
placeholder="Share your thoughts and press Enter…"
14251386
focused={inputFocused}
14261387
maxHeight={5}
1427-
theme={theme}
14281388
width={inputWidth}
14291389
onKeyIntercept={handleSuggestionMenuKey}
14301390
textAttributes={theme.messageTextAttributes}
@@ -1439,19 +1399,17 @@ export const App = ({
14391399
>
14401400
<AgentModeToggle
14411401
mode={agentMode}
1442-
theme={theme}
14431402
onToggle={toggleAgentMode}
14441403
/>
14451404
</box>
14461405
</box>
1447-
<Separator theme={theme} width={separatorWidth} />
1406+
<Separator width={separatorWidth} />
14481407
</box>
14491408

14501409
{/* Login Modal Overlay - show when not authenticated and done checking */}
14511410
{requireAuth !== null && isAuthenticated === false && (
14521411
<LoginModal
14531412
onLoginSuccess={handleLoginSuccess}
1454-
theme={theme}
14551413
hasInvalidCredentials={hasInvalidCredentials}
14561414
/>
14571415
)}

cli/src/components/__tests__/status-indicator.timer.test.tsx

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@ import {
1313
} from 'bun:test'
1414

1515
import { StatusIndicator } from '../status-indicator'
16-
import { chatThemes } from '../../utils/theme-system'
16+
import { ThemeProvider } from '../../hooks/use-theme'
1717
import { renderToStaticMarkup } from 'react-dom/server'
1818
import * as codebuffClient from '../../utils/codebuff-client'
1919

20-
const theme = chatThemes.dark
21-
2220
const createTimer = (elapsedSeconds: number, started: boolean) => ({
2321
start: () => {},
2422
stop: () => {},
@@ -41,36 +39,39 @@ describe('StatusIndicator timer rendering', () => {
4139

4240
test('shows elapsed seconds when timer is active', () => {
4341
const markup = renderToStaticMarkup(
44-
<StatusIndicator
45-
theme={theme}
46-
clipboardMessage={null}
47-
isActive={true}
48-
timer={createTimer(5, true)}
49-
/>,
42+
<ThemeProvider>
43+
<StatusIndicator
44+
clipboardMessage={null}
45+
isActive={true}
46+
timer={createTimer(5, true)}
47+
/>
48+
</ThemeProvider>,
5049
)
5150

5251
expect(markup).toContain('5s')
5352

5453
const inactiveMarkup = renderToStaticMarkup(
55-
<StatusIndicator
56-
theme={theme}
57-
clipboardMessage={null}
58-
isActive={false}
59-
timer={createTimer(0, false)}
60-
/>,
54+
<ThemeProvider>
55+
<StatusIndicator
56+
clipboardMessage={null}
57+
isActive={false}
58+
timer={createTimer(0, false)}
59+
/>
60+
</ThemeProvider>,
6161
)
6262

6363
expect(inactiveMarkup).toBe('')
6464
})
6565

6666
test('clipboard message takes priority over timer output', () => {
6767
const markup = renderToStaticMarkup(
68-
<StatusIndicator
69-
theme={theme}
70-
clipboardMessage="Copied!"
71-
isActive={true}
72-
timer={createTimer(12, true)}
73-
/>,
68+
<ThemeProvider>
69+
<StatusIndicator
70+
clipboardMessage="Copied!"
71+
isActive={true}
72+
timer={createTimer(12, true)}
73+
/>
74+
</ThemeProvider>,
7475
)
7576

7677
expect(markup).toContain('Copied!')

cli/src/components/agent-mode-toggle.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,33 @@
1-
import { AgentMode } from '../utils/constants'
2-
import type { ChatTheme } from '../utils/theme-system'
31
import { RaisedPill } from './raised-pill'
2+
import { useTheme } from '../hooks/use-theme'
3+
4+
import type { AgentMode } from '../utils/constants'
5+
import type { ChatTheme } from '../utils/theme-system'
6+
7+
const getModeConfig = (theme: ChatTheme) =>
8+
({
9+
FAST: {
10+
frameColor: theme.modeToggleFastBg,
11+
textColor: theme.modeToggleFastText,
12+
label: 'FAST',
13+
},
14+
MAX: {
15+
frameColor: theme.modeToggleMaxBg,
16+
textColor: theme.modeToggleMaxText,
17+
label: '💪 MAX',
18+
},
19+
}) as const
420

521
export const AgentModeToggle = ({
622
mode,
7-
theme,
823
onToggle,
924
}: {
10-
mode: AgentMode,
11-
theme: ChatTheme
25+
mode: AgentMode
1226
onToggle: () => void
1327
}) => {
14-
const isFast = mode === 'FAST'
15-
const frameColor = isFast ? theme.modeToggleFastBg : theme.modeToggleMaxBg
16-
const textColor = isFast ? theme.modeToggleFastText : theme.modeToggleMaxText
17-
const label = isFast ? 'FAST' : '💪 MAX'
28+
const theme = useTheme()
29+
const config = getModeConfig(theme)
30+
const { frameColor, textColor, label } = config[mode]
1831

1932
return (
2033
<RaisedPill

cli/src/components/login-modal.tsx

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useLoginKeyboardHandlers } from '../hooks/use-login-keyboard-handlers'
99
import { useLoginPolling } from '../hooks/use-login-polling'
1010
import { useLogo } from '../hooks/use-logo'
1111
import { useSheenAnimation } from '../hooks/use-sheen-animation'
12+
import { useTheme, VariantProvider } from '../hooks/use-theme'
1213
import {
1314
LINK_COLOR_DEFAULT,
1415
LINK_COLOR_CLICKED,
@@ -23,7 +24,6 @@ import {
2324
import {
2425
formatUrl,
2526
generateFingerprintId,
26-
isLightModeColor,
2727
calculateResponsiveLayout,
2828
calculateModalDimensions,
2929
} from '../login/utils'
@@ -32,21 +32,35 @@ import { copyTextToClipboard } from '../utils/clipboard'
3232
import { logger } from '../utils/logger'
3333

3434
import type { User } from '../utils/auth'
35-
import type { ChatTheme } from '../utils/theme-system'
3635

3736
interface LoginModalProps {
3837
onLoginSuccess: (user: User) => void
39-
theme: ChatTheme
4038
hasInvalidCredentials?: boolean | null
4139
}
4240

4341
export const LoginModal = ({
4442
onLoginSuccess,
45-
theme,
4643
hasInvalidCredentials = false,
44+
}: LoginModalProps) => {
45+
return (
46+
<VariantProvider variant="modal">
47+
<LoginModalContent
48+
onLoginSuccess={onLoginSuccess}
49+
hasInvalidCredentials={hasInvalidCredentials}
50+
/>
51+
</VariantProvider>
52+
)
53+
}
54+
55+
const LoginModalContent = ({
56+
onLoginSuccess,
57+
hasInvalidCredentials,
4758
}: LoginModalProps) => {
4859
const renderer = useRenderer()
4960

61+
// Use theme from context (will be modal variant due to VariantProvider)
62+
const theme = useTheme()
63+
5064
// Use zustand store for all state
5165
const {
5266
loginUrl,
@@ -219,28 +233,6 @@ export const LoginModal = ({
219233
}
220234
}, [hasOpenedBrowser, loginUrl, copyToClipboard])
221235

222-
// Determine if we're in light mode by checking text colors
223-
// Note: We check text color instead of background because theme.background is 'transparent'
224-
// In light mode: text is dark (#1f2937)
225-
// In dark mode: text is light (#ffffff)
226-
const isLightMode = useMemo(() => {
227-
const textColor = theme.messageAiText
228-
if (textColor && textColor !== 'default' && textColor.startsWith('#')) {
229-
const textIsLight = isLightModeColor(textColor)
230-
// Light text = dark background = dark mode
231-
// Dark text = light background = light mode
232-
return !textIsLight
233-
}
234-
// Fallback to dark mode if we can't determine
235-
return false
236-
}, [theme.messageAiText])
237-
238-
// Use pure black/white for logo
239-
const logoColor = isLightMode ? '#000000' : '#ffffff'
240-
241-
// Use solid background colors for the modal (instead of transparent theme.background)
242-
const modalBackground = isLightMode ? '#ffffff' : '#000000'
243-
244236
// Calculate terminal width and height for responsive display
245237
const terminalWidth = renderer?.width || 80
246238
const terminalHeight = renderer?.height || 24
@@ -278,7 +270,7 @@ export const LoginModal = ({
278270

279271
// Use custom hook for sheen animation
280272
const { applySheenToChar } = useSheenAnimation({
281-
logoColor,
273+
logoColor: theme.logoColor,
282274
terminalWidth: renderer?.width,
283275
sheenPosition,
284276
setSheenPosition,
@@ -319,7 +311,7 @@ export const LoginModal = ({
319311
width: modalWidth,
320312
height: modalHeight,
321313
maxHeight: modalHeight,
322-
backgroundColor: modalBackground,
314+
backgroundColor: theme.background,
323315
padding: 0,
324316
flexDirection: 'column',
325317
}}
@@ -352,7 +344,7 @@ export const LoginModal = ({
352344
alignItems: 'center',
353345
width: '100%',
354346
height: '100%',
355-
backgroundColor: modalBackground,
347+
backgroundColor: theme.background,
356348
padding: containerPadding,
357349
gap: 0,
358350
}}

0 commit comments

Comments
 (0)