From 5f83f1996b5cb7854cdd4d5c7b6fce98a7b5210e Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 28 Oct 2025 11:21:04 -0700 Subject: [PATCH 01/41] feat(cli): initial welcome screen --- cli/src/chat.tsx | 64 +++++++++++++++++++++------- cli/src/components/message-block.tsx | 52 ++++++++++++---------- cli/src/utils/codebuff-client.ts | 7 ++- 3 files changed, 83 insertions(+), 40 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 7224ee277..50643143f 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -26,6 +26,7 @@ import { useSystemThemeDetector } from './hooks/use-system-theme-detector' import { useChatStore } from './state/chat-store' import { flushAnalytics } from './utils/analytics' import { getUserCredentials } from './utils/auth' +import { LOGO } from './login/constants' import { createChatScrollAcceleration } from './utils/chat-scroll-accel' import { formatQueuedPreview } from './utils/helpers' import { loadLocalAgents } from './utils/local-agent-registry' @@ -42,6 +43,10 @@ type ChatVariant = 'ai' | 'user' | 'agent' const MAX_VIRTUALIZED_TOP_LEVEL = 60 const VIRTUAL_OVERSCAN = 12 +const LOGO_BLOCK = LOGO.split('\n') + .filter((line) => line.length > 0) + .join('\n') + type AgentMessage = { agentName: string agentType: string @@ -137,17 +142,20 @@ export const App = ({ const authQuery = useAuthQuery() const logoutMutation = useLogoutMutation() - // If requireAuth is null (checking), assume not authenticated until proven otherwise - const [isAuthenticated, setIsAuthenticated] = useState( - requireAuth === false ? true : false, + // If requireAuth is null (checking), defer showing auth UI until resolved + const initialAuthState = + requireAuth === false ? true : requireAuth === true ? false : null + const [isAuthenticated, setIsAuthenticated] = useState( + initialAuthState, ) const [user, setUser] = useState(null) // Update authentication state when requireAuth changes useEffect(() => { - if (requireAuth !== null) { - setIsAuthenticated(!requireAuth) + if (requireAuth === null) { + return } + setIsAuthenticated(!requireAuth) }, [requireAuth]) // Update authentication state based on query results @@ -188,18 +196,44 @@ export const App = ({ useEffect(() => { if (loadedAgentsData && messages.length === 0) { const agentListId = 'loaded-agents-list' + const userCredentials = getUserCredentials() + const greeting = userCredentials?.name?.trim().length + ? `Welcome back, ${userCredentials.name.trim()}!` + : null + + const blocks: ContentBlock[] = [ + { + type: 'text', + content: '\n\n' + LOGO_BLOCK, + }, + ] + + if (greeting) { + blocks.push({ + type: 'text', + content: greeting, + }) + } + + blocks.push( + { + type: 'text', + content: + 'Codebuff can read and write files in this repository, and run terminal commands to help you build.', + }, + { + type: 'agent-list', + id: agentListId, + agents: loadedAgentsData.agents, + agentsDir: loadedAgentsData.agentsDir, + }, + ) + const initialMessage: ChatMessage = { id: `system-loaded-agents-${Date.now()}`, variant: 'ai', content: '', // Content is in the block - blocks: [ - { - type: 'agent-list', - id: agentListId, - agents: loadedAgentsData.agents, - agentsDir: loadedAgentsData.agentsDir, - }, - ], + blocks, timestamp: new Date().toISOString(), } @@ -298,7 +332,7 @@ export const App = ({ ) useEffect(() => { - if (!isAuthenticated) return + if (isAuthenticated !== true) return setInputFocused(true) @@ -1010,7 +1044,7 @@ export const App = ({ {/* Login Modal Overlay - show when not authenticated */} - {!isAuthenticated && ( + {isAuthenticated === false && ( TRUNCATE_LIMIT - const displayAgents = - shouldTruncate && isCollapsed ? agents.slice(0, TRUNCATE_LIMIT) : agents + const sortedAgents = [...agents].sort((a, b) => { + const aLabel = (a.displayName || a.id).toLowerCase() + const bLabel = (b.displayName || b.id).toLowerCase() + return aLabel.localeCompare(bLabel) + }) + const agentCount = sortedAgents.length + const previewAgents = sortedAgents.slice(0, TRUNCATE_LIMIT) const remainingCount = - shouldTruncate && isCollapsed ? agentCount - TRUNCATE_LIMIT : 0 + agentCount > TRUNCATE_LIMIT ? agentCount - TRUNCATE_LIMIT : 0 + + const formatIdentifier = (agent: { id: string; displayName: string }) => + agent.displayName && agent.displayName !== agent.id + ? `${agent.displayName} (${agent.id})` + : agent.displayName || agent.id const agentListContent = ( - {displayAgents.map((agent, idx) => { - const identifier = - agent.displayName && agent.displayName !== agent.id - ? `${agent.displayName} (${agent.id})` - : agent.displayName || agent.id + {sortedAgents.map((agent, idx) => { + const identifier = formatIdentifier(agent) return ( {` • ${identifier}`} ) })} - {remainingCount > 0 && ( - - {` ... ${pluralize(remainingCount, 'more agent')}`} - - )} ) - const headerText = `Loaded ${pluralize(agentCount, 'local agent')}` + const headerText = pluralize(agentCount, 'local agent') + const previewLines = previewAgents.map( + (agent) => ` • ${formatIdentifier(agent)}`, + ) const finishedPreview = - shouldTruncate && isCollapsed - ? `${TRUNCATE_LIMIT} agents shown, ${remainingCount} more available` + isCollapsed + ? [ + ...previewLines, + remainingCount > 0 + ? ` ... ${pluralize(remainingCount, 'more agent')} available` + : null, + ] + .filter(Boolean) + .join('\n') : '' return ( diff --git a/cli/src/utils/codebuff-client.ts b/cli/src/utils/codebuff-client.ts index 92cf557cc..2b615bf55 100644 --- a/cli/src/utils/codebuff-client.ts +++ b/cli/src/utils/codebuff-client.ts @@ -1,9 +1,10 @@ import { CodebuffClient } from '@codebuff/sdk' +import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' import { findGitRoot } from './git' -import { logger } from './logger' import { getAuthTokenDetails } from './auth' -import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' +import { loadAgentDefinitions } from './load-agent-definitions' +import { logger } from './logger' let clientInstance: CodebuffClient | null = null @@ -21,9 +22,11 @@ export function getCodebuffClient(): CodebuffClient | null { const gitRoot = findGitRoot() try { + const agentDefinitions = loadAgentDefinitions() clientInstance = new CodebuffClient({ apiKey, cwd: gitRoot, + agentDefinitions, }) } catch (error) { logger.error(error, 'Failed to initialize CodebuffClient') From 80a0325e0895618e126a5ae81769ba8b7f36e035 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 28 Oct 2025 14:59:56 -0700 Subject: [PATCH 02/41] feat(cli): upgrade opentui, add transparent background --- bun.lock | 24 +-- cli/package.json | 4 +- cli/src/hooks/use-system-theme-detector.ts | 60 +------ cli/src/utils/theme-listener-macos.ts | 189 --------------------- cli/src/utils/theme-system.ts | 4 +- 5 files changed, 20 insertions(+), 261 deletions(-) delete mode 100644 cli/src/utils/theme-listener-macos.ts diff --git a/bun.lock b/bun.lock index fa471572b..f24963291 100644 --- a/bun.lock +++ b/bun.lock @@ -84,8 +84,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "^0.1.28", - "@opentui/react": "^0.1.28", + "@opentui/core": "^0.1.31", + "@opentui/react": "^0.1.31", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", @@ -284,7 +284,7 @@ }, "sdk": { "name": "@codebuff/sdk", - "version": "0.5.0", + "version": "0.5.7", "dependencies": { "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@vscode/tree-sitter-wasm": "0.1.4", @@ -1023,21 +1023,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], - "@opentui/core": ["@opentui/core@0.1.28", "", { "dependencies": { "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.28", "@opentui/core-darwin-x64": "0.1.28", "@opentui/core-linux-arm64": "0.1.28", "@opentui/core-linux-x64": "0.1.28", "@opentui/core-win32-arm64": "0.1.28", "@opentui/core-win32-x64": "0.1.28", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-3GOnETvNeYcWcQPGaauNpPxgvglnvCfK4mmr7gkNsnVY5NEnrBbh7yuVHDXRNuzRldG4Aj5JEq7pWRexNhnL6g=="], + "@opentui/core": ["@opentui/core@0.1.31", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.31", "@opentui/core-darwin-x64": "0.1.31", "@opentui/core-linux-arm64": "0.1.31", "@opentui/core-linux-x64": "0.1.31", "@opentui/core-win32-arm64": "0.1.31", "@opentui/core-win32-x64": "0.1.31", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-Q6nL0WFkDDjl3mibdSPppOJbU5mr2f/0iC1+GvydiSvi/iv4CGxaTu6oPyUOK5BVv8ujWFzQ0sR7rc6yv7Jr+Q=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ivJmq6NLNzWRiottzBE5DVLe/fOklj3WwGkFhnRTFDG2nDcc1/uyvvpCZRwkJcb+TpV5zpB8YWzzs3XahGA0oQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.31", "", { "os": "darwin", "cpu": "arm64" }, "sha512-irsQW6XUAwJ5YkWH3OHrAD3LX7MN36RWkNQbUh2/pYCRUa4+bdsh6esFv7eXnDt/fUKAQ+tNtw/6jCo7I3TXMw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.28", "", { "os": "darwin", "cpu": "x64" }, "sha512-tiIiX9S5Gdz0DFfzqOv5WRjJsr+zEHWiYYoUlJ7/H9IRw0cj3Wq9V1LruZKd3WwbXwEVNkrdo9PoGotNGDSUxQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.31", "", { "os": "darwin", "cpu": "x64" }, "sha512-MDxfSloyrl/AzTIgUvEQm61MHSG753f8UzKdg+gZTzUHb7kWwpPfYrzFAVwN9AnURVUMKvTzoFBZ61UxOSIarw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.28", "", { "os": "linux", "cpu": "arm64" }, "sha512-jZ6848fyF8wVElIsCiAC5hBvPjOWlVlpXQFL8XBiu4U47bI/Le6vpp9f/kg9iipFaq2pGxKYcQak1/35Ns+5UQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.31", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+/F3lIsn7aHTqugO5hvdHjwILs/p92P+lAGCK9iBkEX20gTk9dOc6IUpC8iy0eNUJyCjYAilkWtAVIbS+S47Q=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.28", "", { "os": "linux", "cpu": "x64" }, "sha512-xcQhFfCJZGZeq00ODflyRO1EcK1myb0CUaC0grpP2pvKdulHshn6gnLod7EoEHGboP3zzQrffPRjvYgd6JWKJg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.31", "", { "os": "linux", "cpu": "x64" }, "sha512-sjDrN4KIT305dycX5A50jNPCcf7nVLKGkJwY7g4x+eWuOItbRCfChr3CyniABDbUlJkPiB8/tvbM/7tID7mjqQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.28", "", { "os": "win32", "cpu": "arm64" }, "sha512-SuDBSOZVaU/bS9ngs9ADQJaFfg3TmCTl4OBKQgrpGErGpG0fNZJMS4NqJTlBcGOGiT/JxgMIZly/Ku/Q2gdz5A=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.31", "", { "os": "win32", "cpu": "arm64" }, "sha512-4xbr/a75YoskNj0c91RRvib5tV77WTZG4DQVgmSwi8osGIDGZnZjpx5nMYU25m9b7NSJW6+kGYzPy/FHwaZtjg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.28", "", { "os": "win32", "cpu": "x64" }, "sha512-oMO2d9+7HlGuQFX4j9ex31JkS7AiEkktUL0cjQsgqK09zyUz8tQdlb3l/5yzJ2dPJ00K7Ae1K+0HO+5ClADcuQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.31", "", { "os": "win32", "cpu": "x64" }, "sha512-LhyPfR5PuX6hY1LBteAUz5khO8hxV3rLnk2inGEDMffBUkrN2XW0+R635BIIFtq/tYFeTf0mzf+/DwvhiLcgbg=="], - "@opentui/react": ["@opentui/react@0.1.28", "", { "dependencies": { "@opentui/core": "0.1.28", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-ubHPv8ZCgb9nBI6Ibh9FYXAK6A49Wt4ab6AdJW0eIeWOUHAKb+5LlWNO6YS11h+HkPzkcYFZC0uUY08/YXv6qw=="], + "@opentui/react": ["@opentui/react@0.1.31", "", { "dependencies": { "@opentui/core": "0.1.31", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-VG+6PrhuKekHpzMSJlGFV76OiytP55RXMZLz3D4eq19/T6to1GTL97lYgZbsNgxwhl3uB9OY61pr2Jir6/CBkw=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], @@ -1741,6 +1741,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-ffi-structs": ["bun-ffi-structs@0.1.0", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-NoRfJ81pgLIHCzw624/2GS2FuxcU0G4SRJww/4PXvheNVUPSIUjkOC6v1/8rk66tJVCb9oR0D6rDNKK0qT5O2Q=="], + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], "bun-webgpu": ["bun-webgpu@0.1.3", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.3", "bun-webgpu-darwin-x64": "^0.1.3", "bun-webgpu-linux-x64": "^0.1.3", "bun-webgpu-win32-x64": "^0.1.3" } }, "sha512-IXFxaIi4rgsEEpl9n/QVDm5RajCK/0FcOXZeMb52YRjoiAR1YVYK5hLrXT8cm+KDi6LVahA9GJFqOR4yiloVCw=="], diff --git a/cli/package.json b/cli/package.json index 4990bc29e..8e0fd113e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -33,8 +33,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "^0.1.28", - "@opentui/react": "^0.1.28", + "@opentui/core": "^0.1.31", + "@opentui/react": "^0.1.31", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", diff --git a/cli/src/hooks/use-system-theme-detector.ts b/cli/src/hooks/use-system-theme-detector.ts index 69762ec80..157683f63 100644 --- a/cli/src/hooks/use-system-theme-detector.ts +++ b/cli/src/hooks/use-system-theme-detector.ts @@ -1,65 +1,11 @@ -import { useEffect, useRef, useState } from 'react' - -import { logger } from '../utils/logger' -import { - spawnMacOSThemeListener, - type ThemeListenerProcess, -} from '../utils/theme-listener-macos' import { type ThemeName, detectSystemTheme } from '../utils/theme-system' -const DEFAULT_POLL_INTERVAL_MS = 60000 // 60 seconds - /** - * Automatically detects system theme changes. - * On macOS, uses a lightweight background watcher that checks every 0.5s. - * Falls back to slower polling on other platforms or if watcher fails. + * Detects the system theme once on mount. + * No dynamic updates or transitions. * * @returns The current system theme name */ export const useSystemThemeDetector = (): ThemeName => { - const [themeName, setThemeName] = useState(() => - detectSystemTheme(), - ) - const lastThemeRef = useRef(themeName) - const listenerRef = useRef(null) - - useEffect(() => { - logger.info({ themeName }, `[theme] initial theme ${themeName}`) - - const handleThemeChange = () => { - const currentTheme = detectSystemTheme() - - if (currentTheme !== lastThemeRef.current) { - } else { - } - - // Only update state if theme actually changed - if (currentTheme !== lastThemeRef.current) { - lastThemeRef.current = currentTheme - setThemeName(currentTheme) - } - } - - // Try to use macOS listener first (instant, event-driven) - if (process.platform === 'darwin') { - const listener = spawnMacOSThemeListener(handleThemeChange) - if (listener) { - listenerRef.current = listener - // Successfully spawned listener, no need for polling - return () => { - listenerRef.current?.kill() - listenerRef.current = null - } - } - } - - // Fall back to polling for non-macOS or if listener failed - const intervalId = setInterval(handleThemeChange, DEFAULT_POLL_INTERVAL_MS) - - return () => { - clearInterval(intervalId) - } - }, []) - - return themeName + return detectSystemTheme() } diff --git a/cli/src/utils/theme-listener-macos.ts b/cli/src/utils/theme-listener-macos.ts deleted file mode 100644 index a370d18d9..000000000 --- a/cli/src/utils/theme-listener-macos.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { existsSync, watch, type FSWatcher } from 'fs' - -import { getIDEThemeConfigPaths } from './theme-system' - -/** - * macOS theme change listener using polling - * Checks the system theme preference every 0.5 seconds - */ - -// Shell script that polls for theme changes -const WATCH_SCRIPT = ` -# Initial value -LAST_VALUE="" - -while true; do - # Check if AppleInterfaceStyle key exists (Dark mode) - CURRENT_VALUE=$(defaults read -g AppleInterfaceStyle 2>/dev/null || echo "Light") - - # If changed, output notification - if [ "$LAST_VALUE" != "" ] && [ "$CURRENT_VALUE" != "$LAST_VALUE" ]; then - echo "THEME_CHANGED" - fi - - LAST_VALUE="$CURRENT_VALUE" - - # Wait a bit before checking again (very lightweight) - sleep 0.5 -done -` - -const IDE_THEME_DEBOUNCE_MS = 200 - -interface IDEWatcherHandle { - watchers: FSWatcher[] - dispose: () => void -} - -const createIDEThemeWatchers = ( - onThemeChange: () => void, -): IDEWatcherHandle => { - const watchers: FSWatcher[] = [] - const targets = new Set(getIDEThemeConfigPaths()) - - if (targets.size === 0) { - return { - watchers, - dispose: () => {}, - } - } - - let debounceTimer: ReturnType | null = null - const scheduleNotify = () => { - if (debounceTimer) { - clearTimeout(debounceTimer) - } - - debounceTimer = setTimeout(() => { - debounceTimer = null - onThemeChange() - }, IDE_THEME_DEBOUNCE_MS) - } - - for (const path of targets) { - try { - if (!existsSync(path)) { - continue - } - - const watcher = watch(path, { persistent: false }, () => { - scheduleNotify() - }) - - watchers.push(watcher) - } catch { - // Ignore watcher failures (e.g., permissions) - } - } - - return { - watchers, - dispose: () => { - if (debounceTimer) { - clearTimeout(debounceTimer) - debounceTimer = null - } - }, - } -} - -export interface ThemeListenerProcess { - kill: () => void -} - -/** - * Spawns a shell script that watches for macOS theme changes - * @param onThemeChange - Callback invoked when theme changes - * @returns Process handle to clean up later - */ -export const spawnMacOSThemeListener = ( - onThemeChange: () => void, -): ThemeListenerProcess | null => { - if (typeof Bun === 'undefined') { - return null - } - - if (process.platform !== 'darwin') { - return null - } - - const bash = Bun.which('bash') - if (!bash) { - return null - } - - try { - const proc = Bun.spawn({ - cmd: [bash, '-c', WATCH_SCRIPT], - stdout: 'pipe', - stderr: 'pipe', - }) - - const watcherHandle = createIDEThemeWatchers(onThemeChange) - - // Read stderr to prevent blocking - const readStderr = async () => { - const reader = proc.stderr.getReader() - try { - while (true) { - const { done } = await reader.read() - if (done) break - } - } catch { - // Process was killed or errored, ignore - } - } - - readStderr() - - // Read stdout line by line - const readStdout = async () => { - const reader = proc.stdout.getReader() - const decoder = new TextDecoder() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - if (line.trim() === 'THEME_CHANGED') { - onThemeChange() - } - } - } - } catch { - // Process was killed or errored, ignore - } - } - - readStdout() - - return { - kill: () => { - try { - proc.kill() - } catch { - // Ignore errors when killing - } - - for (const watcher of watcherHandle.watchers) { - try { - watcher.close() - } catch { - // Ignore watcher closure errors - } - } - - watcherHandle.dispose() - }, - } - } catch { - return null - } -} diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index bf7a8b829..c164d14f3 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -728,7 +728,7 @@ export const detectSystemTheme = (): ThemeName => { const DEFAULT_CHAT_THEMES: Record = { dark: { - background: '#000000', + background: 'transparent', chromeBg: '#000000', chromeText: '#9ca3af', accentBg: '#facc15', @@ -781,7 +781,7 @@ const DEFAULT_CHAT_THEMES: Record = { }, }, light: { - background: '#ffffff', + background: 'transparent', chromeBg: '#f3f4f6', chromeText: '#374151', accentBg: '#f59e0b', From cff68195b31951d3b075a578a496ea46094036a5 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 28 Oct 2025 18:27:17 -0700 Subject: [PATCH 03/41] Revamp CLI toggle theming and raised pill buttons --- cli/src/chat.tsx | 36 +- cli/src/components/agent-mode-toggle.tsx | 28 +- cli/src/components/branch-item.tsx | 91 +- cli/src/components/login-modal.tsx | 24 +- cli/src/components/message-block.tsx | 32 +- cli/src/components/multiline-input.tsx | 51 +- cli/src/components/raised-pill.tsx | 84 ++ cli/src/components/separator.tsx | 3 +- cli/src/components/suggestion-menu.tsx | 14 +- cli/src/components/terminal-link.tsx | 2 +- cli/src/hooks/use-message-renderer.tsx | 54 +- cli/src/hooks/use-system-theme-detector.ts | 11 - cli/src/utils/syntax-highlighter.tsx | 7 +- cli/src/utils/theme-system.ts | 1128 +++++++------------- 14 files changed, 606 insertions(+), 959 deletions(-) create mode 100644 cli/src/components/raised-pill.tsx delete mode 100644 cli/src/hooks/use-system-theme-detector.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 50643143f..96d87035f 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -22,7 +22,6 @@ import { useMessageRenderer } from './hooks/use-message-renderer' import { useChatScrollbox } from './hooks/use-scroll-management' import { useSendMessage } from './hooks/use-send-message' import { useSuggestionEngine } from './hooks/use-suggestion-engine' -import { useSystemThemeDetector } from './hooks/use-system-theme-detector' import { useChatStore } from './state/chat-store' import { flushAnalytics } from './utils/analytics' import { getUserCredentials } from './utils/auth' @@ -32,7 +31,7 @@ import { formatQueuedPreview } from './utils/helpers' import { loadLocalAgents } from './utils/local-agent-registry' import { logger } from './utils/logger' import { buildMessageTree } from './utils/message-tree-utils' -import { chatThemes, createMarkdownPalette } from './utils/theme-system' +import { chatTheme, createMarkdownPalette } from './utils/theme-system' import type { User } from './utils/auth' import type { ToolName } from '@codebuff/sdk' @@ -55,7 +54,7 @@ type AgentMessage = { } export type ContentBlock = - | { type: 'text'; content: string } + | { type: 'text'; content: string; color?: string } | { type: 'tool' toolCallId: string @@ -127,8 +126,7 @@ export const App = ({ const terminalWidth = resolvedTerminalWidth const separatorWidth = Math.max(1, Math.floor(terminalWidth) - 2) - const themeName = useSystemThemeDetector() - const theme = chatThemes[themeName] + const theme = chatTheme const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) const [exitWarning, setExitWarning] = useState(null) @@ -205,6 +203,7 @@ export const App = ({ { type: 'text', content: '\n\n' + LOGO_BLOCK, + color: theme.agentToggleExpandedBg, }, ] @@ -212,6 +211,7 @@ export const App = ({ blocks.push({ type: 'text', content: greeting, + color: theme.agentResponseCount, }) } @@ -220,6 +220,7 @@ export const App = ({ type: 'text', content: 'Codebuff can read and write files in this repository, and run terminal commands to help you build.', + color: theme.agentResponseCount, }, { type: 'agent-list', @@ -241,7 +242,7 @@ export const App = ({ setCollapsedAgents((prev) => new Set([...prev, agentListId])) setMessages([initialMessage]) } - }, [loadedAgentsData]) // Only run when loadedAgentsData changes + }, [loadedAgentsData, theme]) // Only run when loadedAgentsData changes const { inputValue, @@ -373,10 +374,6 @@ export const App = ({ activeSubagentsRef.current = activeSubagents }, [activeSubagents]) - useEffect(() => { - renderer?.setBackgroundColor(theme.background) - }, [renderer, theme.background]) - useEffect(() => { if (exitArmedRef.current && inputValue.length > 0) { exitArmedRef.current = false @@ -872,7 +869,10 @@ export const App = ({ const virtualizationNotice = shouldVirtualize && hiddenTopLevelCount > 0 ? ( - + Showing latest {virtualTopLevelMessages.length} of{' '} {topLevelMessages.length} messages. Scroll up to load more. @@ -910,7 +910,7 @@ export const App = ({ paddingRight: 0, paddingTop: 0, paddingBottom: 0, - backgroundColor: theme.panelBg, + backgroundColor: 'transparent', }} > @@ -955,7 +955,7 @@ export const App = ({ flexShrink: 0, paddingLeft: 0, paddingRight: 0, - backgroundColor: theme.panelBg, + backgroundColor: 'transparent', }} > {shouldShowStatusLine && ( @@ -966,7 +966,7 @@ export const App = ({ width: '100%', }} > - + {hasStatus ? statusIndicatorNode : null} {hasStatus && (exitWarning || shouldShowQueuePreview) ? ' ' : ''} {exitWarning ? ( @@ -974,7 +974,7 @@ export const App = ({ ) : null} {exitWarning && shouldShowQueuePreview ? ' ' : ''} {shouldShowQueuePreview ? ( - + {' '} {formatQueuedPreview( queuedMessages, diff --git a/cli/src/components/agent-mode-toggle.tsx b/cli/src/components/agent-mode-toggle.tsx index ec71fd0d9..cce08bea0 100644 --- a/cli/src/components/agent-mode-toggle.tsx +++ b/cli/src/components/agent-mode-toggle.tsx @@ -1,4 +1,5 @@ import type { ChatTheme } from '../utils/theme-system' +import { RaisedPill } from './raised-pill' export const AgentModeToggle = ({ mode, @@ -10,25 +11,18 @@ export const AgentModeToggle = ({ onToggle: () => void }) => { const isFast = mode === 'FAST' - - const bgColor = isFast ? '#0a6515' : '#ac1626' - const textColor = '#ffffff' + const frameColor = isFast + ? theme.agentToggleHeaderBg + : theme.agentToggleExpandedBg + const textColor = frameColor const label = isFast ? 'FAST' : '💪 MAX' return ( - - - {label} - - + ) } diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index c032eb7f9..54f851494 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -16,6 +16,7 @@ const borderCharsWithoutVertical: BorderCharacters = { } import type { ChatTheme } from '../utils/theme-system' +import { RaisedPill } from './raised-pill' interface BranchItemProps { name: string @@ -45,16 +46,17 @@ export const BranchItem = ({ onToggle, }: BranchItemProps) => { const cornerColor = theme.agentPrefix - - const toggleBackground = isStreaming - ? theme.agentToggleHeaderBg - : isCollapsed - ? theme.agentResponseCount - : theme.agentPrefix - const toggleTextColor = - (isStreaming ? theme.agentToggleHeaderText : theme.agentToggleText) ?? - theme.agentToggleText + const isExpanded = !isCollapsed + const toggleFrameColor = isExpanded + ? theme.agentToggleExpandedBg + : theme.agentToggleHeaderBg + const toggleIconColor = isStreaming + ? theme.statusAccent + : toggleFrameColor + const toggleLabelColor = toggleFrameColor const toggleLabel = `${isCollapsed ? '▸' : '▾'} ` + const collapseButtonFrame = theme.agentToggleExpandedBg + const collapseButtonText = collapseButtonFrame const isTextRenderable = (value: ReactNode): boolean => { if (value === null || value === undefined || typeof value === 'boolean') { @@ -102,7 +104,7 @@ export const BranchItem = ({ if (isTextRenderable(value)) { return ( - + {value} ) @@ -152,28 +154,24 @@ export const BranchItem = ({ }} > - - - {toggleLabel} - - {name} - - - + {isStreaming && isCollapsed && streamingPreview && ( @@ -183,7 +181,6 @@ export const BranchItem = ({ {!isStreaming && isCollapsed && finishedPreview && ( @@ -209,38 +206,22 @@ export const BranchItem = ({ > {prompt && ( - - Prompt - - - {prompt} - + Prompt + {prompt} - - Response - + Response )} {renderExpandedContent(content)} )} - - - - Collapse - - - + )} diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 5ce99d71a..0ba6574f2 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -268,7 +268,7 @@ export const LoginModal = ({ // Render logo with sheen animation (memoized because it re-renders on sheen position changes) const renderedLogo = useMemo(() => { return logoDisplayLines.map((line, lineIndex) => ( - + {line .split('') .map((char, charIndex) => @@ -301,7 +301,7 @@ export const LoginModal = ({ width: Math.floor(terminalWidth * 0.95), height: modalHeight, maxHeight: modalHeight, - backgroundColor: theme.background, + backgroundColor: 'transparent', padding: 0, flexDirection: 'column', }} @@ -312,13 +312,13 @@ export const LoginModal = ({ style={{ width: '100%', padding: 1, - backgroundColor: '#ff0000', + backgroundColor: 'transparent', borderStyle: 'single', borderColor: WARNING_COLOR, flexShrink: 0, }} > - + {isNarrow ? "⚠ Found API key but it's invalid. Please log in again." @@ -334,7 +334,7 @@ export const LoginModal = ({ alignItems: 'center', width: '100%', height: '100%', - backgroundColor: theme.background, + backgroundColor: 'transparent', padding: containerPadding, gap: 0, }} @@ -363,7 +363,7 @@ export const LoginModal = ({ flexShrink: 0, }} > - + {isNarrow ? 'Codebuff' : 'Codebuff CLI'} @@ -382,7 +382,7 @@ export const LoginModal = ({ flexShrink: 0, }} > - + Loading... @@ -399,11 +399,11 @@ export const LoginModal = ({ flexShrink: 0, }} > - + Error: {error} {!isVerySmall && ( - + {isNarrow ? 'Please try again' @@ -425,7 +425,7 @@ export const LoginModal = ({ flexShrink: 0, }} > - + {isNarrow ? 'Press ENTER to login...' @@ -447,7 +447,7 @@ export const LoginModal = ({ gap: isVerySmall ? 0 : 1, }} > - + {isNarrow ? 'Click to copy:' : 'Click link to copy:'} @@ -493,7 +493,7 @@ export const LoginModal = ({ flexShrink: 0, }} > - + { const identifier = formatIdentifier(agent) return ( - + {` • ${identifier}`} ) @@ -284,17 +284,16 @@ export const MessageBlock = ({ const previewLines = previewAgents.map( (agent) => ` • ${formatIdentifier(agent)}`, ) - const finishedPreview = - isCollapsed - ? [ - ...previewLines, - remainingCount > 0 - ? ` ... ${pluralize(remainingCount, 'more agent')} available` - : null, - ] - .filter(Boolean) - .join('\n') - : '' + const finishedPreview = isCollapsed + ? [ + ...previewLines, + remainingCount > 0 + ? ` ... ${pluralize(remainingCount, 'more agent')} available` + : null, + ] + .filter(Boolean) + .join('\n') + : '' return ( {isUser && ( {`[${timestamp}]`} @@ -418,8 +416,9 @@ export const MessageBlock = ({ (prevBlock.type === 'tool' || prevBlock.type === 'agent') ? 0 : 0 + const blockTextColor = block.color ?? textColor return ( - + {renderedContent} ) @@ -464,7 +463,6 @@ export const MessageBlock = ({ return ( {displayContent} @@ -474,13 +472,13 @@ export const MessageBlock = ({ )} {isAi && isComplete && (completionTime || credits) && ( {completionTime} diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 517890ec4..af16886cd 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -14,47 +14,6 @@ import { useOpentuiPaste } from '../hooks/use-opentui-paste' import type { PasteEvent, ScrollBoxRenderable } from '@opentui/core' -const mixColors = ( - foreground: string, - background: string, - alpha = 0.4, -): string => { - const parseHex = (hex: string) => { - const normalized = hex.trim().replace('#', '') - const full = - normalized.length === 3 - ? normalized - .split('') - .map((ch) => ch + ch) - .join('') - : normalized - const value = parseInt(full, 16) - return { - r: (value >> 16) & 0xff, - g: (value >> 8) & 0xff, - b: value & 0xff, - } - } - - const clamp = (value: number) => Math.max(0, Math.min(255, Math.round(value))) - - try { - const fg = parseHex(foreground) - const bg = parseHex(background) - - const blend = { - r: clamp(alpha * fg.r + (1 - alpha) * bg.r), - g: clamp(alpha * fg.g + (1 - alpha) * bg.g), - b: clamp(alpha * fg.b + (1 - alpha) * bg.b), - } - - const toHex = (value: number) => value.toString(16).padStart(2, '0') - return `#${toHex(blend.r)}${toHex(blend.g)}${toHex(blend.b)}` - } catch { - return foreground - } -} - // Helper functions for text manipulation function findLineStart(text: string, cursor: number): number { let pos = Math.max(0, Math.min(cursor, text.length)) @@ -587,11 +546,6 @@ export const MultilineInput = forwardRef< const beforeCursor = showCursor ? displayValue.slice(0, cursorPosition) : '' const afterCursor = showCursor ? displayValue.slice(cursorPosition) : '' const activeChar = afterCursor.charAt(0) || ' ' - const highlightBg = mixColors( - theme.cursor, - isPlaceholder ? theme.inputBg : theme.inputFocusedBg, - 0.4, - ) const shouldHighlight = showCursor && !isPlaceholder && @@ -638,7 +592,7 @@ export const MultilineInput = forwardRef< rootOptions: { width: '100%', height: height, - backgroundColor: focused ? theme.inputFocusedBg : theme.inputBg, + backgroundColor: 'transparent', flexGrow: 0, flexShrink: 0, }, @@ -653,7 +607,6 @@ export const MultilineInput = forwardRef< }} > {beforeCursor} {shouldHighlight ? ( - + {activeChar === ' ' ? '\u00a0' : activeChar} ) : ( diff --git a/cli/src/components/raised-pill.tsx b/cli/src/components/raised-pill.tsx new file mode 100644 index 000000000..7b96f9854 --- /dev/null +++ b/cli/src/components/raised-pill.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import stringWidth from 'string-width' + +type PillSegment = { + text: string + fg?: string + attr?: number +} + +interface RaisedPillProps { + segments: PillSegment[] + frameColor: string + textColor: string + fillColor?: string + padding?: number + onPress?: () => void + style?: Record +} + +const buildHorizontal = (length: number): string => { + if (length <= 0) return '' + return '─'.repeat(length) +} + +export const RaisedPill = ({ + segments, + frameColor, + textColor, + fillColor, + padding = 1, + onPress, + style, +}: RaisedPillProps): React.ReactNode => { + const leftRightPadding = + padding > 0 ? [{ text: ' '.repeat(padding), fg: textColor }] : [] + + const normalizedSegments: Array<{ text: string; fg: string; attr?: number }> = + [ + ...leftRightPadding, + ...segments.map((segment) => ({ + text: segment.text, + fg: segment.fg ?? textColor, + attr: segment.attr, + })), + ...leftRightPadding, + ] + + const contentText = normalizedSegments.map((segment) => segment.text).join('') + const contentWidth = Math.max(0, stringWidth(contentText)) + const horizontal = buildHorizontal(contentWidth) + + return ( + + + {`╭${horizontal}╮`} + + + + {normalizedSegments.map((segment, idx) => ( + + {segment.text} + + ))} + + + + {`╰${horizontal}╯`} + + + ) +} diff --git a/cli/src/components/separator.tsx b/cli/src/components/separator.tsx index 7904c149d..cae7ee192 100644 --- a/cli/src/components/separator.tsx +++ b/cli/src/components/separator.tsx @@ -11,8 +11,7 @@ export const Separator = ({ theme, width }: SeparatorProps) => { return ( ) } diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index 20452f8ed..bbf3a1ac0 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -54,7 +54,7 @@ export const SuggestionMenu = ({ paddingRight: 1, paddingTop: 0, paddingBottom: 0, - backgroundColor: theme.panelBg, + backgroundColor: 'transparent', width: '100%', }} > @@ -62,7 +62,7 @@ export const SuggestionMenu = ({ style={{ flexDirection: 'column', gap: 0, - backgroundColor: theme.messageBg, + backgroundColor: 'transparent', width: '100%', }} > @@ -72,9 +72,9 @@ export const SuggestionMenu = ({ const labelLength = effectivePrefix.length + item.label.length const paddingLength = Math.max(maxLabelLength - labelLength + 2, 2) const padding = ' '.repeat(paddingLength) - const textColor = isSelected ? theme.agentContentText : theme.inputFg + const textColor = isSelected ? theme.statusAccent : theme.inputFg const descriptionColor = isSelected - ? theme.agentContentText + ? theme.statusAccent : theme.timestampUser return ( {effectivePrefix} diff --git a/cli/src/components/terminal-link.tsx b/cli/src/components/terminal-link.tsx index d2adf62b6..6dbbc42be 100644 --- a/cli/src/components/terminal-link.tsx +++ b/cli/src/components/terminal-link.tsx @@ -62,7 +62,7 @@ export const TerminalLink: React.FC = ({ onMouseDown={handleActivate} > {displayLines.map((line, index) => ( - + {shouldUnderline ? ( {line} diff --git a/cli/src/hooks/use-message-renderer.tsx b/cli/src/hooks/use-message-renderer.tsx index 307d00511..3aeacde5c 100644 --- a/cli/src/hooks/use-message-renderer.tsx +++ b/cli/src/hooks/use-message-renderer.tsx @@ -1,7 +1,21 @@ -import { TextAttributes } from '@opentui/core' +import { TextAttributes, type BorderCharacters } from '@opentui/core' import { useMemo, type ReactNode } from 'react' import React from 'react' +const verticalLineBorderChars: BorderCharacters = { + topLeft: ' ', + topRight: ' ', + bottomLeft: ' ', + bottomRight: ' ', + horizontal: ' ', + vertical: '│', + topT: ' ', + bottomT: ' ', + leftT: ' ', + rightT: ' ', + cross: ' ', +} + import { MessageBlock } from '../components/message-block' import { renderMarkdown, @@ -58,6 +72,11 @@ export const useMessageRenderer = ( const agentInfo = message.agent! const isCollapsed = collapsedAgents.has(message.id) const isStreaming = streamingAgents.has(message.id) + const toggleColor = isStreaming + ? theme.statusAccent + : isCollapsed + ? theme.agentResponseCount + : theme.agentPrefix const agentChildren = messageTree.get(message.id) ?? [] @@ -157,7 +176,7 @@ export const useMessageRenderer = ( flexShrink: 0, }} > - + {fullPrefix} - - - {isCollapsed ? '▸ ' : '▾ '} - - + + {isCollapsed ? '▸ ' : '▾ '} + {agentInfo.agentName} @@ -197,17 +208,12 @@ export const useMessageRenderer = ( onMouseDown={handleContentClick} > {isStreaming && isCollapsed && streamingPreview && ( - + {streamingPreview} )} {!isStreaming && isCollapsed && finishedPreview && ( @@ -217,7 +223,6 @@ export const useMessageRenderer = ( {!isCollapsed && ( {displayContent} @@ -319,16 +324,19 @@ export const useMessageRenderer = ( }} > { - return detectSystemTheme() -} diff --git a/cli/src/utils/syntax-highlighter.tsx b/cli/src/utils/syntax-highlighter.tsx index 6f77326ba..057d8727f 100644 --- a/cli/src/utils/syntax-highlighter.tsx +++ b/cli/src/utils/syntax-highlighter.tsx @@ -9,16 +9,11 @@ interface HighlightOptions { export function highlightCode( code: string, lang: string, - bg: string, options: HighlightOptions = {}, ): ReactNode { const { fg = 'brightWhite' } = options // For now, just return the code with basic styling // Can be enhanced later with actual syntax highlighting - return ( - - {code} - - ) + return {code} } diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index c164d14f3..90902aa45 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -1,475 +1,10 @@ -import { existsSync, readFileSync, readdirSync, statSync } from 'fs' -import { homedir } from 'os' -import { join } from 'path' +import fs from 'node:fs' +import { execSync } from 'node:child_process' import type { MarkdownPalette } from './markdown-renderer' -const IDE_THEME_INFERENCE = { - dark: [ - 'dark', - 'midnight', - 'night', - 'noir', - 'black', - 'charcoal', - 'dim', - 'dracula', - 'darcula', - 'moon', - 'nebula', - 'obsidian', - 'shadow', - 'storm', - 'monokai', - 'ayu mirage', - 'material darker', - 'tokyo', - 'abyss', - 'zed dark', - ], - light: [ - 'light', - 'day', - 'dawn', - 'bright', - 'paper', - 'sun', - 'snow', - 'cloud', - 'white', - 'solarized light', - 'pastel', - 'cream', - 'zed light', - ], -} as const - -const VS_CODE_FAMILY_ENV_KEYS = [ - 'VSCODE_PID', - 'VSCODE_CWD', - 'VSCODE_IPC_HOOK_CLI', - 'VSCODE_LOG_NATIVE', - 'VSCODE_NLS_CONFIG', - 'CURSOR_SESSION_ID', - 'CURSOR', -] - -const VS_CODE_PRODUCT_DIRS = [ - 'Code', - 'Code - Insiders', - 'Code - OSS', - 'VSCodium', - 'VSCodium - Insiders', - 'Cursor', -] - -const JETBRAINS_ENV_KEYS = [ - 'JB_PRODUCT_CODE', - 'JB_SYSTEM_PATH', - 'JB_INSTALLATION_HOME', - 'IDEA_INITIAL_DIRECTORY', - 'IDE_CONFIG_DIR', - 'JB_IDE_CONFIG_DIR', -] - -const normalizeThemeName = (themeName: string): string => - themeName.trim().toLowerCase() - -const inferThemeFromName = (themeName: string): ThemeName | null => { - const normalized = normalizeThemeName(themeName) - - for (const hint of IDE_THEME_INFERENCE.dark) { - if (normalized.includes(hint)) { - return 'dark' - } - } - - for (const hint of IDE_THEME_INFERENCE.light) { - if (normalized.includes(hint)) { - return 'light' - } - } - - return null -} - -const stripJsonStyleComments = (raw: string): string => - raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') - -const safeReadFile = (filePath: string): string | null => { - try { - return readFileSync(filePath, 'utf8') - } catch { - return null - } -} - -const collectExistingPaths = (candidates: string[]): string[] => { - const seen = new Set() - for (const candidate of candidates) { - if (!candidate) continue - try { - if (existsSync(candidate)) { - seen.add(candidate) - } - } catch { - // Ignore filesystem errors when probing paths - } - } - return [...seen] -} - -const resolveVSCodeSettingsPaths = (): string[] => { - const settings: string[] = [] - const home = homedir() - - if (process.platform === 'darwin') { - const base = join(home, 'Library', 'Application Support') - for (const product of VS_CODE_PRODUCT_DIRS) { - settings.push(join(base, product, 'User', 'settings.json')) - } - } else if (process.platform === 'win32') { - const appData = process.env.APPDATA - if (appData) { - for (const product of VS_CODE_PRODUCT_DIRS) { - settings.push(join(appData, product, 'User', 'settings.json')) - } - } - } else { - const configDir = process.env.XDG_CONFIG_HOME ?? join(home, '.config') - for (const product of VS_CODE_PRODUCT_DIRS) { - settings.push(join(configDir, product, 'User', 'settings.json')) - } - } - - return settings -} - -const resolveJetBrainsLafPaths = (): string[] => { - const candidates: string[] = [] - - for (const key of ['IDE_CONFIG_DIR', 'JB_IDE_CONFIG_DIR']) { - const raw = process.env[key] - if (raw) { - candidates.push(join(raw, 'options', 'laf.xml')) - } - } - - const home = homedir() - - const baseDirs: string[] = [] - if (process.platform === 'darwin') { - baseDirs.push(join(home, 'Library', 'Application Support', 'JetBrains')) - } else if (process.platform === 'win32') { - const appData = process.env.APPDATA - if (appData) { - baseDirs.push(join(appData, 'JetBrains')) - } - } else { - baseDirs.push(join(home, '.config', 'JetBrains')) - baseDirs.push(join(home, '.local', 'share', 'JetBrains')) - } - - for (const base of baseDirs) { - try { - if (!existsSync(base)) continue - const entries = readdirSync(base) - for (const entry of entries) { - const dirPath = join(base, entry) - try { - if (!statSync(dirPath).isDirectory()) continue - } catch { - continue - } - - candidates.push(join(dirPath, 'options', 'laf.xml')) - } - } catch { - // Ignore unreadable directories - } - } - - return candidates -} - -const resolveZedSettingsPaths = (): string[] => { - const home = homedir() - const paths: string[] = [] - - const configDirs = new Set() - - const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, '.config') - configDirs.add(join(xdgConfig, 'zed')) - configDirs.add(join(xdgConfig, 'dev.zed.Zed')) - - if (process.platform === 'darwin') { - configDirs.add(join(home, 'Library', 'Application Support', 'Zed')) - configDirs.add(join(home, 'Library', 'Application Support', 'dev.zed.Zed')) - } else if (process.platform === 'win32') { - const appData = process.env.APPDATA - if (appData) { - configDirs.add(join(appData, 'Zed')) - configDirs.add(join(appData, 'dev.zed.Zed')) - } - } else { - configDirs.add(join(home, '.config', 'zed')) - configDirs.add(join(home, '.config', 'dev.zed.Zed')) - configDirs.add(join(home, '.local', 'share', 'zed')) - configDirs.add(join(home, '.local', 'share', 'dev.zed.Zed')) - } - - const legacyConfig = join(home, '.zed') - configDirs.add(legacyConfig) - - for (const dir of configDirs) { - paths.push(join(dir, 'settings.json')) - } - - return paths -} - -const extractVSCodeTheme = (content: string): ThemeName | null => { - const colorThemeMatch = content.match( - /"workbench\.colorTheme"\s*:\s*"([^"]+)"/i, - ) - if (colorThemeMatch) { - const inferred = inferThemeFromName(colorThemeMatch[1]) - if (inferred) return inferred - } - - const themeKindEnv = - process.env.VSCODE_THEME_KIND ?? process.env.VSCODE_COLOR_THEME_KIND - if (themeKindEnv) { - const normalized = themeKindEnv.trim().toLowerCase() - if (normalized === 'dark' || normalized === 'hc') return 'dark' - if (normalized === 'light') return 'light' - } - - return null -} - -const extractJetBrainsTheme = (content: string): ThemeName | null => { - const normalized = content.toLowerCase() - if (normalized.includes('darcula') || normalized.includes('dark')) { - return 'dark' - } - - if (normalized.includes('light')) { - return 'light' - } - - return null -} - -const isVSCodeFamilyTerminal = (): boolean => { - if (process.env.TERM_PROGRAM?.toLowerCase() === 'vscode') { - return true - } - - for (const key of VS_CODE_FAMILY_ENV_KEYS) { - if (process.env[key]) { - return true - } - } - - return false -} - -const isJetBrainsTerminal = (): boolean => { - if (process.env.TERMINAL_EMULATOR?.toLowerCase().includes('jetbrains')) { - return true - } - - for (const key of JETBRAINS_ENV_KEYS) { - if (process.env[key]) { - return true - } - } - - return false -} - -const detectVSCodeTheme = (): ThemeName | null => { - if (!isVSCodeFamilyTerminal()) { - return null - } - - const settingsPaths = collectExistingPaths(resolveVSCodeSettingsPaths()) - - for (const settingsPath of settingsPaths) { - const content = safeReadFile(settingsPath) - if (!content) continue - const theme = extractVSCodeTheme(content) - if (theme) { - return theme - } - } - - const themeKindEnv = - process.env.VSCODE_THEME_KIND ?? process.env.VSCODE_COLOR_THEME_KIND - if (themeKindEnv) { - const normalized = themeKindEnv.trim().toLowerCase() - if (normalized === 'dark' || normalized === 'hc') return 'dark' - if (normalized === 'light') return 'light' - } - - return null -} - -const detectJetBrainsTheme = (): ThemeName | null => { - if (!isJetBrainsTerminal()) { - return null - } - - const lafPaths = collectExistingPaths(resolveJetBrainsLafPaths()) - - for (const lafPath of lafPaths) { - const content = safeReadFile(lafPath) - if (!content) continue - const theme = extractJetBrainsTheme(content) - if (theme) { - return theme - } - } - - return null -} - -const extractZedTheme = (content: string): ThemeName | null => { - try { - const sanitized = stripJsonStyleComments(content) - const parsed = JSON.parse(sanitized) as Record - const candidates: unknown[] = [] - - const themeSetting = parsed.theme - if (typeof themeSetting === 'string') { - candidates.push(themeSetting) - } else if (themeSetting && typeof themeSetting === 'object') { - const themeConfig = themeSetting as Record - const modeRaw = themeConfig.mode - if (typeof modeRaw === 'string') { - const mode = modeRaw.toLowerCase() - if (mode === 'dark' || mode === 'light') { - candidates.push(mode) - const modeTheme = themeConfig[mode] - if (typeof modeTheme === 'string') { - candidates.push(modeTheme) - } - } else if (mode === 'system') { - const platformTheme = detectPlatformTheme() - candidates.push(platformTheme) - const platformThemeName = themeConfig[platformTheme] - if (typeof platformThemeName === 'string') { - candidates.push(platformThemeName) - } - } - } - - const darkTheme = themeConfig.dark - if (typeof darkTheme === 'string') { - candidates.push(darkTheme) - } - - const lightTheme = themeConfig.light - if (typeof lightTheme === 'string') { - candidates.push(lightTheme) - } - } - - const appearance = parsed.appearance - if (appearance && typeof appearance === 'object') { - const appearanceTheme = (appearance as Record).theme - if (typeof appearanceTheme === 'string') { - candidates.push(appearanceTheme) - } - - const preference = (appearance as Record) - .theme_preference - if (typeof preference === 'string') { - candidates.push(preference) - } - } - - const ui = parsed.ui - if (ui && typeof ui === 'object') { - const uiTheme = (ui as Record).theme - if (typeof uiTheme === 'string') { - candidates.push(uiTheme) - } - } - - for (const candidate of candidates) { - if (typeof candidate !== 'string') continue - - const inferred = inferThemeFromName(candidate) - if (inferred) { - return inferred - } - } - } catch { - // Ignore malformed or partially written files - } - - return null -} - -const detectZedTheme = (): ThemeName | null => { - const settingsPaths = collectExistingPaths(resolveZedSettingsPaths()) - for (const settingsPath of settingsPaths) { - const content = safeReadFile(settingsPath) - if (!content) continue - - const theme = extractZedTheme(content) - if (theme) { - return theme - } - } - - return null -} - -const detectIDETheme = (): ThemeName | null => { - const detectors = [detectVSCodeTheme, detectJetBrainsTheme, detectZedTheme] - for (const detector of detectors) { - const theme = detector() - if (theme) { - return theme - } - } - return null -} - -export const getIDEThemeConfigPaths = (): string[] => { - const paths = new Set() - for (const path of resolveVSCodeSettingsPaths()) { - paths.add(path) - } - for (const path of resolveJetBrainsLafPaths()) { - paths.add(path) - } - for (const path of resolveZedSettingsPaths()) { - paths.add(path) - } - return [...paths] -} - -export type ThemeName = 'dark' | 'light' - type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 -interface MarkdownThemeOverrides { - codeBackground?: string - codeHeaderFg?: string - inlineCodeFg?: string - codeTextFg?: string - headingFg?: Partial> - listBulletFg?: string - blockquoteBorderFg?: string - blockquoteTextFg?: string - dividerFg?: string - codeMonochrome?: boolean -} - export interface ChatTheme { background: string chromeBg: string @@ -502,269 +37,62 @@ export interface ChatTheme { agentToggleHeaderBg: string agentToggleHeaderText: string agentToggleText: string + agentToggleExpandedBg: string agentContentBg: string - markdown?: MarkdownThemeOverrides -} - -type ChatThemeOverrides = Partial> & { - markdown?: MarkdownThemeOverrides -} - -type ThemeOverrideConfig = Partial> & { - all?: ChatThemeOverrides -} - -const CHAT_THEME_ENV_KEYS = [ - 'OPEN_TUI_CHAT_THEME_OVERRIDES', - 'OPENTUI_CHAT_THEME_OVERRIDES', -] - -const mergeMarkdownOverrides = ( - base: MarkdownThemeOverrides | undefined, - override: MarkdownThemeOverrides | undefined, -): MarkdownThemeOverrides | undefined => { - if (!base && !override) return undefined - if (!override) - return base - ? { - ...base, - headingFg: base.headingFg ? { ...base.headingFg } : undefined, - } - : undefined - - const mergedHeading = { - ...(base?.headingFg ?? {}), - ...(override.headingFg ?? {}), - } - - return { - ...(base ?? {}), - ...override, - headingFg: - Object.keys(mergedHeading).length > 0 - ? (mergedHeading as Partial>) - : undefined, + markdown?: { + headingFg?: Partial> + inlineCodeFg?: string + codeBackground?: string + codeHeaderFg?: string + listBulletFg?: string + blockquoteBorderFg?: string + blockquoteTextFg?: string + dividerFg?: string + codeTextFg?: string + codeMonochrome?: boolean } } -const mergeTheme = ( - base: ChatTheme, - override?: ChatThemeOverrides, -): ChatTheme => { - if (!override) { - return { - ...base, - markdown: base.markdown - ? { - ...base.markdown, - headingFg: base.markdown.headingFg - ? { ...base.markdown.headingFg } - : undefined, - } - : undefined, - } - } - - return { - ...base, - ...override, - markdown: mergeMarkdownOverrides(base.markdown, override.markdown), - } -} - -const parseThemeOverrides = ( - raw: string, -): Partial> => { - try { - const parsed = JSON.parse(raw) as ThemeOverrideConfig - if (!parsed || typeof parsed !== 'object') return {} - - const result: Partial> = {} - const common = - typeof parsed.all === 'object' && parsed.all ? parsed.all : undefined - - for (const themeName of ['dark', 'light'] as ThemeName[]) { - const specific = - typeof parsed?.[themeName] === 'object' && parsed?.[themeName] - ? parsed?.[themeName] - : undefined - - const mergedOverrides = - common || specific - ? { - ...(common ?? {}), - ...(specific ?? {}), - markdown: mergeMarkdownOverrides( - common?.markdown, - specific?.markdown, - ), - } - : undefined - - if (mergedOverrides) { - result[themeName] = mergedOverrides - } - } - - return result - } catch { - return {} - } -} - -const loadThemeOverrides = (): Partial< - Record -> => { - for (const key of CHAT_THEME_ENV_KEYS) { - const raw = process.env[key] - if (raw && raw.trim().length > 0) { - return parseThemeOverrides(raw) - } - } - return {} -} - -const textDecoder = new TextDecoder() - -const readSpawnOutput = (output: unknown): string => { - if (!output) return '' - if (typeof output === 'string') return output.trim() - if (output instanceof Uint8Array) return textDecoder.decode(output).trim() - return '' -} - -const runSystemCommand = (command: string[]): string | null => { - if (typeof Bun === 'undefined') return null - if (command.length === 0) return null - - const [binary] = command - if (!binary) return null - - const resolvedBinary = - Bun.which(binary) ?? - (process.platform === 'win32' ? Bun.which(`${binary}.exe`) : null) - if (!resolvedBinary) return null - - try { - const result = Bun.spawnSync({ - cmd: [resolvedBinary, ...command.slice(1)], - stdout: 'pipe', - stderr: 'pipe', - }) - if (result.exitCode !== 0) return null - return readSpawnOutput(result.stdout) - } catch { - return null - } -} - -function detectPlatformTheme(): ThemeName { - if (typeof Bun !== 'undefined') { - if (process.platform === 'darwin') { - const value = runSystemCommand([ - 'defaults', - 'read', - '-g', - 'AppleInterfaceStyle', - ]) - if (value?.toLowerCase() === 'dark') return 'dark' - return 'light' - } - - if (process.platform === 'win32') { - const value = runSystemCommand([ - 'powershell', - '-NoProfile', - '-Command', - '(Get-ItemProperty -Path HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize).AppsUseLightTheme', - ]) - if (value === '0') return 'dark' - if (value === '1') return 'light' - } - - if (process.platform === 'linux') { - const value = runSystemCommand([ - 'gsettings', - 'get', - 'org.gnome.desktop.interface', - 'color-scheme', - ]) - if (value?.toLowerCase().includes('dark')) return 'dark' - if (value?.toLowerCase().includes('light')) return 'light' - } - } - - return 'dark' -} - -export const detectSystemTheme = (): ThemeName => { - const envPreference = process.env.OPEN_TUI_THEME ?? process.env.OPENTUI_THEME - const normalizedEnv = envPreference?.toLowerCase() - - if (normalizedEnv === 'dark' || normalizedEnv === 'light') { - return normalizedEnv - } - - // Detect Ghostty terminal and default to dark. - if ( - (typeof Bun !== 'undefined' && - Bun.env.GHOSTTY_RESOURCES_DIR !== undefined) || - process.env.GHOSTTY_RESOURCES_DIR !== undefined || - (process.env.TERM ?? '').toLowerCase() === 'xterm-ghostty' - ) { - return 'dark' - } - - const ideTheme = detectIDETheme() - const platformTheme = detectPlatformTheme() - const preferredTheme = ideTheme ?? platformTheme - - if (normalizedEnv === 'opposite') { - return preferredTheme === 'dark' ? 'light' : 'dark' - } - - return preferredTheme -} - -const DEFAULT_CHAT_THEMES: Record = { +const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { dark: { background: 'transparent', - chromeBg: '#000000', - chromeText: '#9ca3af', - accentBg: '#facc15', - accentText: '#1c1917', - panelBg: '#000000', + chromeBg: 'transparent', + chromeText: '#e2e8f0', + accentBg: 'transparent', + accentText: '#facc15', + panelBg: 'transparent', aiLine: '#34d399', userLine: '#38bdf8', timestampAi: '#4ade80', timestampUser: '#60a5fa', - messageAiText: '#f1f5f9', - messageUserText: '#dbeafe', - messageBg: '#000000', + messageAiText: '#ffffff', + messageUserText: '#ffffff', + messageBg: 'transparent', statusAccent: '#facc15', - statusSecondary: '#a3aed0', - inputBg: '#000000', - inputFg: '#f5f5f5', - inputFocusedBg: '#000000', + statusSecondary: '#d9e2ff', + inputBg: 'transparent', + inputFg: '#ffffff', + inputFocusedBg: 'transparent', inputFocusedFg: '#ffffff', - inputPlaceholder: '#a3a3a3', + inputPlaceholder: '#cbd5f5', cursor: '#22c55e', agentPrefix: '#22c55e', agentName: '#4ade80', - agentText: '#d1d5db', + agentText: '#ffffff', agentCheckmark: '#22c55e', - agentResponseCount: '#9ca3af', - agentFocusedBg: '#334155', - agentContentText: '#ffffff', - agentToggleHeaderBg: '#f97316', - agentToggleHeaderText: '#ffffff', - agentToggleText: '#ffffff', - agentContentBg: '#000000', + agentResponseCount: '#94a3b8', + agentFocusedBg: 'transparent', + agentContentText: '#e2e8f0', + agentToggleHeaderBg: '#475569', + agentToggleHeaderText: '#f8fafc', + agentToggleText: '#f8fafc', + agentToggleExpandedBg: '#047857', + agentContentBg: 'transparent', markdown: { - codeBackground: '#1f2933', - codeHeaderFg: '#5b647a', - inlineCodeFg: '#f1f5f9', - codeTextFg: '#f1f5f9', + codeBackground: 'transparent', + codeHeaderFg: '#d9e2ff', + inlineCodeFg: '#e2e8f0', + codeTextFg: '#e2e8f0', headingFg: { 1: '#facc15', 2: '#facc15', @@ -773,32 +101,32 @@ const DEFAULT_CHAT_THEMES: Record = { 5: '#facc15', 6: '#facc15', }, - listBulletFg: '#a3aed0', - blockquoteBorderFg: '#334155', - blockquoteTextFg: '#e2e8f0', - dividerFg: '#283042', + listBulletFg: '#d9e2ff', + blockquoteBorderFg: '#4b5563', + blockquoteTextFg: '#ffffff', + dividerFg: '#334155', codeMonochrome: true, }, }, light: { background: 'transparent', - chromeBg: '#f3f4f6', - chromeText: '#374151', - accentBg: '#f59e0b', - accentText: '#111827', - panelBg: '#ffffff', + chromeBg: 'transparent', + chromeText: '#334155', + accentBg: 'transparent', + accentText: '#f59e0b', + panelBg: 'transparent', aiLine: '#059669', userLine: '#3b82f6', timestampAi: '#047857', timestampUser: '#2563eb', messageAiText: '#111827', messageUserText: '#1f2937', - messageBg: '#ffffff', + messageBg: 'transparent', statusAccent: '#f59e0b', statusSecondary: '#6b7280', - inputBg: '#f9fafb', + inputBg: 'transparent', inputFg: '#111827', - inputFocusedBg: '#ffffff', + inputFocusedBg: 'transparent', inputFocusedFg: '#000000', inputPlaceholder: '#9ca3af', cursor: '#3b82f6', @@ -806,18 +134,19 @@ const DEFAULT_CHAT_THEMES: Record = { agentName: '#047857', agentText: '#1f2937', agentCheckmark: '#059669', - agentResponseCount: '#6b7280', - agentFocusedBg: '#f3f4f6', - agentContentText: '#111827', - agentToggleHeaderBg: '#ea580c', - agentToggleHeaderText: '#ffffff', - agentToggleText: '#ffffff', - agentContentBg: '#ffffff', + agentResponseCount: '#64748b', + agentFocusedBg: 'transparent', + agentContentText: '#475569', + agentToggleHeaderBg: '#94a3b8', + agentToggleHeaderText: '#f8fafc', + agentToggleText: '#f8fafc', + agentToggleExpandedBg: '#047857', + agentContentBg: 'transparent', markdown: { - codeBackground: '#f3f4f6', - codeHeaderFg: '#6b7280', + codeBackground: 'transparent', + codeHeaderFg: '#4b5563', inlineCodeFg: '#dc2626', - codeTextFg: '#111827', + codeTextFg: '#475569', headingFg: { 1: '#dc2626', 2: '#dc2626', @@ -835,13 +164,332 @@ const DEFAULT_CHAT_THEMES: Record = { }, } -export const chatThemes = (() => { - const overrides = loadThemeOverrides() +const getNormalizedEnvTheme = (): 'dark' | 'light' | null => { + const raw = process.env.OPEN_TUI_THEME ?? process.env.OPENTUI_THEME + if (!raw) return null + const normalized = raw.trim().toLowerCase() + if (normalized === 'dark' || normalized === 'light') { + return normalized + } + return null +} + +const ANSI_BASE_COLORS: Array<[number, number, number]> = [ + [0, 0, 0], + [128, 0, 0], + [0, 128, 0], + [128, 128, 0], + [0, 0, 128], + [128, 0, 128], + [0, 128, 128], + [192, 192, 192], + [128, 128, 128], + [255, 0, 0], + [0, 255, 0], + [255, 255, 0], + [0, 0, 255], + [255, 0, 255], + [0, 255, 255], + [255, 255, 255], +] + +const ANSI_COLOR_CUBE_STEPS = [0, 95, 135, 175, 215, 255] + +const coerceAnsiIndexToRgb = (index: number): [number, number, number] => { + if (!Number.isFinite(index) || index < 0) { + return [0, 0, 0] + } + + if (index < ANSI_BASE_COLORS.length) { + return ANSI_BASE_COLORS[index] + } + + if (index >= 232) { + const level = Math.min(23, Math.max(0, index - 232)) + const value = 8 + level * 10 + return [value, value, value] + } + + const cubeIndex = Math.min(215, Math.max(0, index - 16)) + const r = Math.floor(cubeIndex / 36) + const g = Math.floor((cubeIndex % 36) / 6) + const b = cubeIndex % 6 + return [ + ANSI_COLOR_CUBE_STEPS[r], + ANSI_COLOR_CUBE_STEPS[g], + ANSI_COLOR_CUBE_STEPS[b], + ] +} + +const estimateBrightness = (rgb: [number, number, number]): number => { + const [r, g, b] = rgb + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +const detectThemeFromColorFgbg = (): 'dark' | 'light' | null => { + const colorFgbg = process.env.COLORFGBG + if (!colorFgbg) return null + const parts = colorFgbg.split(';') + if (parts.length === 0) return null + const backgroundRaw = parts[parts.length - 1] + const backgroundValue = Number.parseInt(backgroundRaw, 10) + if (Number.isNaN(backgroundValue)) return null + + const brightness = estimateBrightness(coerceAnsiIndexToRgb(backgroundValue)) + return brightness >= 160 ? 'light' : 'dark' +} + +const OSC_BACKGROUND_QUERY = '\u001b]11;?\u0007' + +const parseOscColorComponent = (component: string): number | null => { + if (!component) return null + if (!/^[0-9A-Fa-f]+$/.test(component)) return null + const value = Number.parseInt(component, 16) + if (!Number.isFinite(value)) return null + const bitLength = component.length * 4 + const maxValue = (1 << bitLength) - 1 + if (maxValue <= 0) return null + return Math.round((value / maxValue) * 255) +} + +const parseOscColor = (payload: string): [number, number, number] | null => { + if (!payload) return null + const trimmed = payload.trim() + const lowerTrimmed = trimmed.toLowerCase() + + if (lowerTrimmed.startsWith('rgb:') || lowerTrimmed.startsWith('rgba:')) { + const separatorIndex = trimmed.indexOf(':') + const parts = trimmed + .slice(separatorIndex + 1) + .split('/') + .filter((part) => part.length > 0) + + if (parts.length < 3) return null + + const [r, g, b] = parts.slice(0, 3).map(parseOscColorComponent) + if ( + r === null || + g === null || + b === null || + Number.isNaN(r) || + Number.isNaN(g) || + Number.isNaN(b) + ) { + return null + } + return [r, g, b] + } + + if (trimmed.startsWith('#')) { + const hex = trimmed.slice(1) + + if (hex.length === 3 || hex.length === 4) { + const r = Number.parseInt(hex[0] + hex[0], 16) + const g = Number.parseInt(hex[1] + hex[1], 16) + const b = Number.parseInt(hex[2] + hex[2], 16) + if ([r, g, b].some((value) => Number.isNaN(value))) { + return null + } + return [r, g, b] + } + + if (hex.length === 6 || hex.length === 8) { + const baseHex = hex.length === 8 ? hex.slice(0, 6) : hex + const r = Number.parseInt(baseHex.slice(0, 2), 16) + const g = Number.parseInt(baseHex.slice(2, 4), 16) + const b = Number.parseInt(baseHex.slice(4, 6), 16) + if ([r, g, b].some((value) => Number.isNaN(value))) { + return null + } + return [r, g, b] + } + } + + return null +} + +let detectedTerminalBackground: [number, number, number] | null = null + +const detectThemeFromTerminalBackground = (): 'dark' | 'light' | null => { + if (process.platform === 'win32') return null + if (!process.stdout.isTTY) return null + + let fd: number | null = null + try { + fd = fs.openSync( + '/dev/tty', + fs.constants.O_RDWR | + fs.constants.O_NOCTTY | + fs.constants.O_NONBLOCK, + ) + } catch { + return null + } + + try { + fs.writeSync(fd, OSC_BACKGROUND_QUERY) + const start = Date.now() + const buffer = Buffer.alloc(256) + let response = '' + + while (Date.now() - start < 100) { + let bytesRead = 0 + try { + bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'EAGAIN' + ) { + continue + } + return null + } + + if (bytesRead > 0) { + response += buffer.toString('utf8', 0, bytesRead) + if (/\u0007|\u001b\\/.test(response)) { + break + } + } else { + break + } + } + + const match = response.match(/\u001b]11;([^\u0007\u001b]*)(?:\u0007|\u001b\\)/) + if (!match) return null + + const rgb = parseOscColor(match[1]) + if (!rgb) return null + detectedTerminalBackground = rgb + + const brightness = estimateBrightness(rgb) + return brightness >= 160 ? 'light' : 'dark' + } catch { + return null + } finally { + if (fd !== null) { + try { + fs.closeSync(fd) + } catch { + // Ignore close errors + } + } + } +} + +const detectThemeFromSystemAppearance = (): 'dark' | 'light' | null => { + if (process.platform !== 'darwin') return null + try { + const output = execSync('defaults read -g AppleInterfaceStyle', { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }).trim() + if (!output) return 'light' + const normalized = output.toLowerCase() + if (normalized === 'dark' || normalized === 'light') { + return normalized + } + } catch { + return null + } + return null +} + +const resolvedThemeName: 'dark' | 'light' = + getNormalizedEnvTheme() ?? + detectThemeFromColorFgbg() ?? + detectThemeFromTerminalBackground() ?? + detectThemeFromSystemAppearance() ?? + 'light' + +const resolveAutoTextColor = ( + baseTheme: ChatTheme, + themeName: 'dark' | 'light', + background: [number, number, number] | null, +): string => { + const DARK_NEUTRAL = '#e2e8f0' + const LIGHT_NEUTRAL = '#475569' + + const fallback = themeName === 'dark' ? DARK_NEUTRAL : LIGHT_NEUTRAL + + if (background) { + const brightness = estimateBrightness(background) + if (brightness <= 150) { + return DARK_NEUTRAL + } + if (brightness >= 195) { + return LIGHT_NEUTRAL + } + // Blend between the two neutrals for mid-tone backgrounds + const ratio = (brightness - 150) / (195 - 150) + const mix = (start: number, end: number) => + Math.round(start + (end - start) * ratio) + + const startRgb = { + r: Number.parseInt(DARK_NEUTRAL.slice(1, 3), 16), + g: Number.parseInt(DARK_NEUTRAL.slice(3, 5), 16), + b: Number.parseInt(DARK_NEUTRAL.slice(5, 7), 16), + } + const endRgb = { + r: Number.parseInt(LIGHT_NEUTRAL.slice(1, 3), 16), + g: Number.parseInt(LIGHT_NEUTRAL.slice(3, 5), 16), + b: Number.parseInt(LIGHT_NEUTRAL.slice(5, 7), 16), + } + + const blended = [ + mix(startRgb.r, endRgb.r), + mix(startRgb.g, endRgb.g), + mix(startRgb.b, endRgb.b), + ] + .map((value) => value.toString(16).padStart(2, '0')) + .join('') + + return `#${blended}` + } + + return fallback +} + +const copyThemeWithAutoText = ( + baseTheme: ChatTheme, + themeName: 'dark' | 'light', + background: [number, number, number] | null, +): ChatTheme => { + const autoTextColor = resolveAutoTextColor(baseTheme, themeName, background) + const markdown = baseTheme.markdown + ? { + ...baseTheme.markdown, + inlineCodeFg: autoTextColor, + codeTextFg: autoTextColor, + blockquoteTextFg: autoTextColor, + } + : undefined + return { - dark: mergeTheme(DEFAULT_CHAT_THEMES.dark, overrides.dark), - light: mergeTheme(DEFAULT_CHAT_THEMES.light, overrides.light), + ...baseTheme, + messageAiText: autoTextColor, + messageUserText: autoTextColor, + inputFg: autoTextColor, + inputFocusedFg: autoTextColor, + agentText: autoTextColor, + agentContentText: autoTextColor, + chromeText: + themeName === 'dark' && autoTextColor === '#f8fafc' + ? '#f8fafc' + : themeName === 'light' && autoTextColor === '#111827' + ? '#111827' + : baseTheme.chromeText, + markdown, } -})() +} + +export const chatTheme: ChatTheme = copyThemeWithAutoText( + BASE_THEMES[resolvedThemeName], + resolvedThemeName, + detectedTerminalBackground, +) export const createMarkdownPalette = (theme: ChatTheme): MarkdownPalette => { const headingDefaults: Record = { From 8a875dd127384b741ac15f8a4f9e6f4050708da4 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 28 Oct 2025 19:52:18 -0700 Subject: [PATCH 04/41] Add toggle open flag and tighten collapse spacing --- cli/src/chat.tsx | 11 ++++++++--- cli/src/components/branch-item.tsx | 6 +++--- cli/src/index.tsx | 16 +++++++++++++++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 96d87035f..434bbb529 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -100,6 +100,7 @@ export const App = ({ requireAuth, hasInvalidCredentials, loadedAgentsData, + initialToggleState, }: { initialPrompt: string | null agentId?: string @@ -109,6 +110,7 @@ export const App = ({ agents: Array<{ id: string; displayName: string }> agentsDir: string } | null + initialToggleState: 'open' | 'closed' | null }) => { const renderer = useRenderer() const { width: measuredWidth } = useTerminalDimensions() @@ -128,6 +130,7 @@ export const App = ({ const theme = chatTheme const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) + const shouldCollapseByDefault = initialToggleState !== 'open' const [exitWarning, setExitWarning] = useState(null) const exitArmedRef = useRef(false) @@ -238,11 +241,13 @@ export const App = ({ timestamp: new Date().toISOString(), } - // Set as collapsed by default - setCollapsedAgents((prev) => new Set([...prev, agentListId])) + // Set as collapsed by default unless forced open + if (shouldCollapseByDefault) { + setCollapsedAgents((prev) => new Set([...prev, agentListId])) + } setMessages([initialMessage]) } - }, [loadedAgentsData, theme]) // Only run when loadedAgentsData changes + }, [loadedAgentsData, theme, shouldCollapseByDefault]) // Only run when loadedAgentsData changes const { inputValue, diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index 54f851494..9e60203d3 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -150,7 +150,7 @@ export const BranchItem = ({ gap: 0, flexShrink: 0, marginTop: 1, - marginBottom: 1, + marginBottom: 0, }} > @@ -188,7 +188,7 @@ export const BranchItem = ({ )} {!isCollapsed && ( - + {content && ( )} diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 3b142d4d8..3133744d1 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -37,6 +37,7 @@ type ParsedArgs = { initialPrompt: string | null agent?: string clearLogs: boolean + toggleState: 'open' | 'closed' | null } function parseArgs(): ParsedArgs { @@ -51,6 +52,10 @@ function parseArgs(): ParsedArgs { 'Specify which agent to use (e.g., "base", "ask", "file-picker")', ) .option('--clear-logs', 'Remove any existing CLI log files before starting') + .option( + '--toggle ', + 'Force initial toggle state (open | closed)', + ) .helpOption('-h, --help', 'Show this help message') .argument('[prompt...]', 'Initial prompt to send to the agent') .allowExcessArguments(true) @@ -63,10 +68,18 @@ function parseArgs(): ParsedArgs { initialPrompt: args.length > 0 ? args.join(' ') : null, agent: options.agent, clearLogs: options.clearLogs || false, + toggleState: + typeof options.toggle === 'string' + ? options.toggle.trim().toLowerCase() === 'open' + ? 'open' + : options.toggle.trim().toLowerCase() === 'closed' + ? 'closed' + : null + : null, } } -const { initialPrompt, agent, clearLogs } = parseArgs() +const { initialPrompt, agent, clearLogs, toggleState } = parseArgs() if (clearLogs) { clearLogFile() @@ -122,6 +135,7 @@ const AppWithAsyncAuth = () => { requireAuth={requireAuth} hasInvalidCredentials={hasInvalidCredentials} loadedAgentsData={loadedAgentsData} + initialToggleState={toggleState} /> ) } From 286b38136ce405ec3448f18fd0f59ce39a9815ee Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 01:50:31 -0700 Subject: [PATCH 05/41] Refine agent tool rendering --- cli/src/components/branch-item.tsx | 225 ++++++++++++++++-------- cli/src/components/login-modal-utils.ts | 16 -- cli/src/components/login-modal.tsx | 10 +- cli/src/components/message-block.tsx | 54 +++--- cli/src/components/tool-item.tsx | 123 +++++++++++++ cli/src/hooks/use-message-renderer.tsx | 146 +++++++-------- cli/src/hooks/use-send-message.ts | 77 +++++++- cli/src/login/utils.ts | 16 -- cli/src/utils/theme-system.ts | 106 ++--------- 9 files changed, 443 insertions(+), 330 deletions(-) create mode 100644 cli/src/components/tool-item.tsx diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index 9e60203d3..fee667937 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -1,18 +1,18 @@ import { TextAttributes, type BorderCharacters } from '@opentui/core' import React, { type ReactNode } from 'react' -const borderCharsWithoutVertical: BorderCharacters = { - topLeft: '┌', - topRight: '┐', - bottomLeft: '└', - bottomRight: '┘', +const containerBorderChars: BorderCharacters = { + topLeft: '╭', + topRight: '╮', + bottomLeft: '╰', + bottomRight: '╯', horizontal: '─', - vertical: ' ', - topT: ' ', - bottomT: ' ', - leftT: ' ', - rightT: ' ', - cross: ' ', + vertical: '│', + topT: '┬', + bottomT: '┴', + leftT: '├', + rightT: '┤', + cross: '┼', } import type { ChatTheme } from '../utils/theme-system' @@ -25,9 +25,12 @@ interface BranchItemProps { agentId?: string isCollapsed: boolean isStreaming: boolean - branchChar: string streamingPreview: string finishedPreview: string + availableWidth: number + statusLabel?: string + statusColor?: string + statusIndicator?: string theme: ChatTheme onToggle: () => void } @@ -39,13 +42,15 @@ export const BranchItem = ({ agentId, isCollapsed, isStreaming, - branchChar, streamingPreview, finishedPreview, + availableWidth, + statusLabel, + statusColor, + statusIndicator = '●', theme, onToggle, }: BranchItemProps) => { - const cornerColor = theme.agentPrefix const isExpanded = !isCollapsed const toggleFrameColor = isExpanded ? theme.agentToggleExpandedBg @@ -57,6 +62,16 @@ export const BranchItem = ({ const toggleLabel = `${isCollapsed ? '▸' : '▾'} ` const collapseButtonFrame = theme.agentToggleExpandedBg const collapseButtonText = collapseButtonFrame + const separatorColor = theme.agentResponseCount + const innerContentWidth = Math.max(0, Math.floor(availableWidth) - 4) + const horizontalLine = + innerContentWidth > 0 ? '─'.repeat(innerContentWidth) : '' + const statusText = + statusLabel && statusLabel.length > 0 + ? statusIndicator === '✓' + ? `${statusLabel} ${statusIndicator}` + : `${statusIndicator} ${statusLabel}` + : null const isTextRenderable = (value: ReactNode): boolean => { if (value === null || value === undefined || typeof value === 'boolean') { @@ -151,80 +166,142 @@ export const BranchItem = ({ flexShrink: 0, marginTop: 1, marginBottom: 0, + width: '100%', }} > - - - - {isStreaming && isCollapsed && streamingPreview && ( - - {streamingPreview} + + {prompt ? ( + + Prompt + + {prompt} - )} - {!isStreaming && isCollapsed && finishedPreview && ( - + ) : null} + + + {toggleLabel} + - {finishedPreview} - - )} - {!isCollapsed && ( - - {content && ( + {name} + + {statusText ? ( + + {` ${statusText}`} + + ) : null} + + + + {isCollapsed ? ( + (isStreaming && streamingPreview) || (!isStreaming && finishedPreview) ? ( + + + {isStreaming ? streamingPreview : finishedPreview} + + + ) : null + ) : ( + <> + {horizontalLine && ( + + + {horizontalLine} + + + )} + + {prompt && ( - {prompt && ( - - Prompt - {prompt} - - Response - + Prompt + + {prompt} + + {content && ( + + Response + )} - {renderExpandedContent(content)} )} - + {renderExpandedContent(content)} + + + - )} - + + )} ) diff --git a/cli/src/components/login-modal-utils.ts b/cli/src/components/login-modal-utils.ts index b3afb0074..f28f5e18b 100644 --- a/cli/src/components/login-modal-utils.ts +++ b/cli/src/components/login-modal-utils.ts @@ -2,22 +2,6 @@ * Utility functions for the login screen component */ -/** - * Calculates the relative luminance of a hex color to determine if it's light or dark mode - */ -export function isLightModeColor(hexColor: string): boolean { - if (!hexColor) return false - - const hex = hexColor.replace('#', '') - const r = parseInt(hex.substring(0, 2), 16) - const g = parseInt(hex.substring(2, 4), 16) - const b = parseInt(hex.substring(4, 6), 16) - - // Calculate relative luminance - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - return luminance > 0.5 -} - /** * Formats a URL for display by wrapping it at logical breakpoints */ diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 0ba6574f2..2d303b274 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -23,7 +23,6 @@ import { import { formatUrl, generateFingerprintId, - isLightModeColor, parseLogoLines, calculateResponsiveLayout, calculateModalDimensions, @@ -222,14 +221,7 @@ export const LoginModal = ({ } }, [hasOpenedBrowser, loginUrl, copyToClipboard]) - // Determine if we're in light mode by checking background color luminance - const isLightMode = useMemo( - () => isLightModeColor(theme.background), - [theme.background], - ) - - // Use pure black/white for logo - const logoColor = isLightMode ? '#000000' : '#ffffff' + const logoColor = theme.chromeText // Use custom hook for sheen animation const { applySheenToChar } = useSheenAnimation({ diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 4e4e99e10..9ab6b9854 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -4,6 +4,7 @@ import React, { type ReactNode } from 'react' import { pluralize } from '@codebuff/common/util/string' import { BranchItem } from './branch-item' +import { ToolItem } from './tool-item' import { getToolDisplayInfo } from '../utils/codebuff-client' import { renderMarkdown, @@ -66,19 +67,6 @@ export const MessageBlock = ({ onToggleCollapsed, registerAgentRef, }: MessageBlockProps): ReactNode => { - const computeBranchChar = (indentLevel: number, isLastBranch: boolean) => - `${' '.repeat(indentLevel)}${isLastBranch ? '└─ ' : '├─ '}` - - const hasBranchAfter = ( - sourceBlocks: ContentBlock[] | undefined, - currentIndex: number, - ): boolean => - !!sourceBlocks - ?.slice(currentIndex + 1) - .some( - (candidate) => candidate.type === 'tool' || candidate.type === 'agent', - ) - const getAgentMarkdownOptions = (indentLevel: number) => { const indentationOffset = indentLevel * 2 @@ -95,7 +83,6 @@ export const MessageBlock = ({ const renderToolBranch = ( toolBlock: Extract, indentLevel: number, - isLastBranch: boolean, keyPrefix: string, ): React.ReactNode => { if (toolBlock.toolName === 'end_turn') { @@ -154,20 +141,19 @@ export const MessageBlock = ({ ? renderMarkdown(fullContent, agentMarkdownOptions) : fullContent - const branchChar = computeBranchChar(indentLevel, isLastBranch) + const indentationOffset = indentLevel * 2 return ( registerAgentRef(toolBlock.toolCallId, el)} + style={{ flexDirection: 'column', gap: 0, marginLeft: indentationOffset }} > - , indentLevel: number, - isLastBranch: boolean, keyPrefix: string, ): React.ReactNode { const isCollapsed = collapsedAgents.has(agentBlock.agentId) @@ -206,7 +191,6 @@ export const MessageBlock = ({ ? sanitizePreview(agentBlock.initialPrompt) : '' - const branchChar = computeBranchChar(indentLevel, isLastBranch) const childNodes = renderAgentBody( agentBlock, indentLevel + 1, @@ -218,12 +202,22 @@ export const MessageBlock = ({ childNodes.length > 0 ? ( {childNodes} ) : null + const indentationOffset = indentLevel * 2 + const branchWidth = Math.max(1, availableWidth - indentationOffset) + const isActive = isStreaming || agentBlock.status === 'running' + const statusLabel = isActive + ? 'running' + : agentBlock.status === 'complete' + ? 'completed' + : agentBlock.status + const statusColor = isActive ? theme.statusAccent : theme.agentResponseCount + const statusIndicator = isActive ? '●' : '✓' return ( registerAgentRef(agentBlock.agentId, el)} - style={{ flexDirection: 'column', gap: 0 }} + style={{ flexDirection: 'column', gap: 0, marginLeft: indentationOffset }} > onToggleCollapsed(agentBlock.agentId)} /> @@ -244,7 +241,6 @@ export const MessageBlock = ({ function renderAgentListBranch( agentListBlock: Extract, - isLastBranch: boolean, keyPrefix: string, ): React.ReactNode { const TRUNCATE_LIMIT = 5 @@ -306,9 +302,9 @@ export const MessageBlock = ({ agentId={agentListBlock.id} isCollapsed={isCollapsed} isStreaming={false} - branchChar="" streamingPreview="" finishedPreview={finishedPreview} + availableWidth={availableWidth} theme={theme} onToggle={() => onToggleCollapsed(agentListBlock.id)} /> @@ -355,22 +351,18 @@ export const MessageBlock = ({ , ) } else if (nestedBlock.type === 'tool') { - const isLastBranch = !hasBranchAfter(nestedBlocks, nestedIdx) nodes.push( renderToolBranch( nestedBlock, indentLevel, - isLastBranch, `${keyPrefix}-tool-${nestedBlock.toolCallId}`, ), ) } else if (nestedBlock.type === 'agent') { - const isLastBranch = !hasBranchAfter(nestedBlocks, nestedIdx) nodes.push( renderAgentBranch( nestedBlock, indentLevel, - isLastBranch, `${keyPrefix}-agent-${nestedIdx}`, ), ) @@ -423,26 +415,20 @@ export const MessageBlock = ({ ) } else if (block.type === 'tool') { - const isLastBranch = !hasBranchAfter(blocks, idx) return renderToolBranch( block, 0, - isLastBranch, `${messageId}-tool-${block.toolCallId}`, ) } else if (block.type === 'agent') { - const isLastBranch = !hasBranchAfter(blocks, idx) return renderAgentBranch( block, 0, - isLastBranch, `${messageId}-agent-${block.agentId}`, ) } else if (block.type === 'agent-list') { - const isLastBranch = !hasBranchAfter(blocks, idx) return renderAgentListBranch( block, - isLastBranch, `${messageId}-agent-list-${block.id}`, ) } diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx new file mode 100644 index 000000000..b10f76252 --- /dev/null +++ b/cli/src/components/tool-item.tsx @@ -0,0 +1,123 @@ +import { TextAttributes } from '@opentui/core' +import React, { type ReactNode } from 'react' + +import type { ChatTheme } from '../utils/theme-system' + +interface ToolItemProps { + name: string + content: ReactNode + isCollapsed: boolean + isStreaming: boolean + streamingPreview: string + finishedPreview: string + theme: ChatTheme + onToggle: () => void +} + +const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { + if ( + value === null || + value === undefined || + value === false || + value === true + ) { + return null + } + + if (typeof value === 'string' || typeof value === 'number') { + return ( + + {value} + + ) + } + + if (Array.isArray(value)) { + return ( + + {value.map((child, index) => ( + + {renderContent(child, theme)} + + ))} + + ) + } + + if (React.isValidElement(value)) { + return value + } + + return ( + + {value as any} + + ) +} + +export const ToolItem = ({ + name, + content, + isCollapsed, + isStreaming, + streamingPreview, + finishedPreview, + theme, + onToggle, +}: ToolItemProps) => { + const toggleColor = theme.statusSecondary + const toggleIcon = isCollapsed ? '▸' : '▾' + const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount + + return ( + + + + {toggleIcon} + + {name} + + + + {isCollapsed ? ( + (isStreaming && streamingPreview) || (!isStreaming && finishedPreview) ? ( + + + {isStreaming ? streamingPreview : finishedPreview} + + + ) : null + ) : ( + + {renderContent(content, theme)} + + )} + + ) +} diff --git a/cli/src/hooks/use-message-renderer.tsx b/cli/src/hooks/use-message-renderer.tsx index 3aeacde5c..5eebeddfd 100644 --- a/cli/src/hooks/use-message-renderer.tsx +++ b/cli/src/hooks/use-message-renderer.tsx @@ -66,8 +66,6 @@ export const useMessageRenderer = ( const renderAgentMessage = ( message: ChatMessage, depth: number, - isLastSibling: boolean, - ancestorBranches: boolean[] = [], ): ReactNode => { const agentInfo = message.agent! const isCollapsed = collapsedAgents.has(message.id) @@ -80,13 +78,6 @@ export const useMessageRenderer = ( const agentChildren = messageTree.get(message.id) ?? [] - let branchPrefix = '' - for (let i = 0; i < ancestorBranches.length; i++) { - branchPrefix += ' ' - } - const treeBranch = isLastSibling ? '└─ ' : '├─ ' - const fullPrefix = branchPrefix + treeBranch - const lines = message.content.split('\n').filter((line) => line.trim()) const firstLine = lines[0] || '' const lastLine = lines[lines.length - 1] || firstLine @@ -101,6 +92,16 @@ export const useMessageRenderer = ( ? lastLine.replace(/[#*_`~\[\]()]/g, '').trim() : '' + const statusColor = isStreaming + ? theme.statusAccent + : theme.agentResponseCount + const statusLabel = isStreaming ? 'running' : 'completed' + const statusIndicator = isStreaming ? '●' : '✓' + const statusText = + statusIndicator === '✓' + ? `${statusLabel} ${statusIndicator}` + : `${statusIndicator} ${statusLabel}` + const agentCodeBlockWidth = Math.max(10, availableWidth - 12) const agentPalette: MarkdownPalette = { ...markdownPalette, @@ -168,67 +169,61 @@ export const useMessageRenderer = ( flexDirection: 'column', gap: 0, flexShrink: 0, + marginLeft: Math.max(0, depth * 2), }} > - - {fullPrefix} - - - - {isCollapsed ? '▸ ' : '▾ '} - - {agentInfo.agentName} - + + {isCollapsed ? '▸ ' : '▾ '} + + {agentInfo.agentName} + + + {` ${statusText}`} + + + + + {isStreaming && isCollapsed && streamingPreview && ( + + {streamingPreview} - - - {isStreaming && isCollapsed && streamingPreview && ( - - {streamingPreview} - - )} - {!isStreaming && isCollapsed && finishedPreview && ( - - {finishedPreview} - - )} - {!isCollapsed && ( - - {displayContent} - - )} - + )} + {!isStreaming && isCollapsed && finishedPreview && ( + + {finishedPreview} + + )} + {!isCollapsed && ( + + {displayContent} + + )} {agentChildren.length > 0 && ( @@ -239,14 +234,9 @@ export const useMessageRenderer = ( flexShrink: 0, }} > - {agentChildren.map((childAgent, idx) => ( + {agentChildren.map((childAgent) => ( - {renderMessageWithAgents( - childAgent, - depth + 1, - idx === agentChildren.length - 1, - [...ancestorBranches, !isLastSibling], - )} + {renderMessageWithAgents(childAgent, depth + 1)} ))} @@ -258,19 +248,12 @@ export const useMessageRenderer = ( const renderMessageWithAgents = ( message: ChatMessage, depth = 0, - isLastSibling = false, - ancestorBranches: boolean[] = [], isLastMessage = false, ): ReactNode => { const isAgent = message.variant === 'agent' if (isAgent) { - return renderAgentMessage( - message, - depth, - isLastSibling, - ancestorBranches, - ) + return renderAgentMessage(message, depth) } const isAi = message.variant === 'ai' @@ -437,13 +420,9 @@ export const useMessageRenderer = ( {hasAgentChildren && ( - {agentChildren.map((agent, idx) => ( + {agentChildren.map((agent) => ( - {renderMessageWithAgents( - agent, - depth + 1, - idx === agentChildren.length - 1, - )} + {renderMessageWithAgents(agent, depth + 1)} ))} @@ -452,10 +431,9 @@ export const useMessageRenderer = ( ) } - return topLevelMessages.map((message, idx) => { - const isLast = idx === topLevelMessages.length - 1 - return renderMessageWithAgents(message, 0, false, [], isLast) - }) + return topLevelMessages.map((message, idx) => + renderMessageWithAgents(message, 0, idx === topLevelMessages.length - 1), + ) }, [ messages, messageTree, diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 9e4a3bb63..1b0d6d446 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -125,6 +125,7 @@ export const useSendMessage = ({ const rootStreamBufferRef = useRef('') const agentStreamAccumulatorsRef = useRef>(new Map()) const rootStreamSeenRef = useRef(false) + const rootLevelAgentsToCollapseRef = useRef>(new Set()) const updateChainInProgress = useCallback( (value: boolean) => { @@ -241,8 +242,23 @@ export const useSendMessage = ({ } }, [flushPendingUpdates]) + const collapseQueuedRootAgents = useCallback(() => { + if (rootLevelAgentsToCollapseRef.current.size === 0) { + return + } + setCollapsedAgents((prev) => { + const next = new Set(prev) + for (const id of rootLevelAgentsToCollapseRef.current) { + next.add(id) + } + return next + }) + rootLevelAgentsToCollapseRef.current.clear() + }, [setCollapsedAgents]) + const sendMessage = useCallback( async (content: string, params: { agentMode: 'FAST' | 'MAX' }) => { + collapseQueuedRootAgents() const { agentMode } = params const timestamp = formatTimestamp() const userMessage: ChatMessage = { @@ -686,6 +702,8 @@ export const useSendMessage = ({ }, 'setMessages: matching spawn_agents block found', ) + let resolvedAsRoot = !event.parentAgentId + applyMessageUpdate((prev) => prev.map((msg) => { if (msg.id === aiMessageId && msg.blocks) { @@ -766,6 +784,7 @@ export const useSendMessage = ({ // If parent found, use updated blocks; otherwise add to top level if (parentFound) { + resolvedAsRoot = false blocks = updatedBlocks } else { logger.info( @@ -776,10 +795,12 @@ export const useSendMessage = ({ }, 'setMessages: spawn_agents parent not found, adding to top level', ) + resolvedAsRoot = true blocks = [...blocks, blockToMove] } } else { // No parent - add back at top level with new ID + resolvedAsRoot = true blocks = [...blocks, blockToMove] } @@ -789,16 +810,24 @@ export const useSendMessage = ({ }), ) + const isRootAgent = resolvedAsRoot + setStreamingAgents((prev) => { const next = new Set(prev) next.delete(tempId) next.add(event.agentId) return next }) + if (isRootAgent) { + rootLevelAgentsToCollapseRef.current.delete(tempId) + rootLevelAgentsToCollapseRef.current.add(event.agentId) + } setCollapsedAgents((prev) => { const next = new Set(prev) next.delete(tempId) - next.add(event.agentId) + if (!isRootAgent) { + next.add(event.agentId) + } return next }) @@ -869,6 +898,7 @@ export const useSendMessage = ({ // If parent was found, use updated blocks; otherwise add to top level if (parentFound) { + resolvedAsRoot = false return { ...msg, blocks: updatedBlocks } } else { logger.info( @@ -879,6 +909,7 @@ export const useSendMessage = ({ 'Parent agent not found, adding to top level', ) // Parent doesn't exist - add at top level as fallback + resolvedAsRoot = true return { ...msg, blocks: [...blocks, newAgentBlock], @@ -887,6 +918,7 @@ export const useSendMessage = ({ } // No parent - add to top level + resolvedAsRoot = true return { ...msg, blocks: [...blocks, newAgentBlock], @@ -894,8 +926,18 @@ export const useSendMessage = ({ }), ) + const isRootAgent = resolvedAsRoot setStreamingAgents((prev) => new Set(prev).add(event.agentId)) - setCollapsedAgents((prev) => new Set(prev).add(event.agentId)) + if (isRootAgent) { + rootLevelAgentsToCollapseRef.current.add(event.agentId) + setCollapsedAgents((prev) => { + const next = new Set(prev) + next.delete(event.agentId) + return next + }) + } else { + setCollapsedAgents((prev) => new Set(prev).add(event.agentId)) + } } } } else if ( @@ -909,6 +951,8 @@ export const useSendMessage = ({ agentStreamAccumulatorsRef.current.delete(event.agentId) removeActiveSubagent(event.agentId) + let resolvedAsRoot = !event.parentAgentId + applyMessageUpdate((prev) => prev.map((msg) => { if (msg.id === aiMessageId && msg.blocks) { @@ -994,12 +1038,23 @@ export const useSendMessage = ({ }), ) - agents.forEach((_: any, index: number) => { - const agentId = `${toolCallId}-${index}` + const spawnAgentIds = agents.map( + (_: any, index: number) => `${toolCallId}-${index}`, + ) + + spawnAgentIds.forEach((agentId) => { setStreamingAgents((prev) => new Set(prev).add(agentId)) - setCollapsedAgents((prev) => new Set(prev).add(agentId)) + rootLevelAgentsToCollapseRef.current.add(agentId) }) + if (spawnAgentIds.length > 0) { + setCollapsedAgents((prev) => { + const next = new Set(prev) + spawnAgentIds.forEach((id) => next.delete(id)) + return next + }) + } + return } @@ -1090,7 +1145,16 @@ export const useSendMessage = ({ } setStreamingAgents((prev) => new Set(prev).add(toolCallId)) - setCollapsedAgents((prev) => new Set(prev).add(toolCallId)) + if (agentId) { + setCollapsedAgents((prev) => new Set(prev).add(toolCallId)) + } else { + rootLevelAgentsToCollapseRef.current.add(toolCallId) + setCollapsedAgents((prev) => { + const next = new Set(prev) + next.delete(toolCallId) + return next + }) + } } else if (event.type === 'tool_result' && event.toolCallId) { const { toolCallId } = event @@ -1343,6 +1407,7 @@ export const useSendMessage = ({ updateChainInProgress, addActiveSubagent, removeActiveSubagent, + collapseQueuedRootAgents, ], ) diff --git a/cli/src/login/utils.ts b/cli/src/login/utils.ts index 74b99f24b..09b524280 100644 --- a/cli/src/login/utils.ts +++ b/cli/src/login/utils.ts @@ -2,22 +2,6 @@ * Utility functions for the login screen component */ -/** - * Calculates the relative luminance of a hex color to determine if it's light or dark mode - */ -export function isLightModeColor(hexColor: string): boolean { - if (!hexColor) return false - - const hex = hexColor.replace('#', '') - const r = parseInt(hex.substring(0, 2), 16) - const g = parseInt(hex.substring(2, 4), 16) - const b = parseInt(hex.substring(4, 6), 16) - - // Calculate relative luminance - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - return luminance > 0.5 -} - /** * Formats a URL for display by wrapping it at logical breakpoints */ diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 90902aa45..62c7b6117 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -65,8 +65,8 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { userLine: '#38bdf8', timestampAi: '#4ade80', timestampUser: '#60a5fa', - messageAiText: '#ffffff', - messageUserText: '#ffffff', + messageAiText: '#9aa5ce', + messageUserText: '#9aa5ce', messageBg: 'transparent', statusAccent: '#facc15', statusSecondary: '#d9e2ff', @@ -119,8 +119,8 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { userLine: '#3b82f6', timestampAi: '#047857', timestampUser: '#2563eb', - messageAiText: '#111827', - messageUserText: '#1f2937', + messageAiText: '#9aa5ce', + messageUserText: '#9aa5ce', messageBg: 'transparent', statusAccent: '#f59e0b', statusSecondary: '#6b7280', @@ -307,9 +307,6 @@ const parseOscColor = (payload: string): [number, number, number] | null => { return null } - -let detectedTerminalBackground: [number, number, number] | null = null - const detectThemeFromTerminalBackground = (): 'dark' | 'light' | null => { if (process.platform === 'win32') return null if (!process.stdout.isTTY) return null @@ -362,7 +359,6 @@ const detectThemeFromTerminalBackground = (): 'dark' | 'light' | null => { const rgb = parseOscColor(match[1]) if (!rgb) return null - detectedTerminalBackground = rgb const brightness = estimateBrightness(rgb) return brightness >= 160 ? 'light' : 'dark' @@ -404,93 +400,21 @@ const resolvedThemeName: 'dark' | 'light' = detectThemeFromSystemAppearance() ?? 'light' -const resolveAutoTextColor = ( - baseTheme: ChatTheme, - themeName: 'dark' | 'light', - background: [number, number, number] | null, -): string => { - const DARK_NEUTRAL = '#e2e8f0' - const LIGHT_NEUTRAL = '#475569' - - const fallback = themeName === 'dark' ? DARK_NEUTRAL : LIGHT_NEUTRAL - - if (background) { - const brightness = estimateBrightness(background) - if (brightness <= 150) { - return DARK_NEUTRAL - } - if (brightness >= 195) { - return LIGHT_NEUTRAL - } - // Blend between the two neutrals for mid-tone backgrounds - const ratio = (brightness - 150) / (195 - 150) - const mix = (start: number, end: number) => - Math.round(start + (end - start) * ratio) - - const startRgb = { - r: Number.parseInt(DARK_NEUTRAL.slice(1, 3), 16), - g: Number.parseInt(DARK_NEUTRAL.slice(3, 5), 16), - b: Number.parseInt(DARK_NEUTRAL.slice(5, 7), 16), - } - const endRgb = { - r: Number.parseInt(LIGHT_NEUTRAL.slice(1, 3), 16), - g: Number.parseInt(LIGHT_NEUTRAL.slice(3, 5), 16), - b: Number.parseInt(LIGHT_NEUTRAL.slice(5, 7), 16), +const baseTheme = BASE_THEMES[resolvedThemeName] +const markdown = baseTheme.markdown + ? { + ...baseTheme.markdown, + headingFg: baseTheme.markdown.headingFg + ? { ...baseTheme.markdown.headingFg } + : undefined, } + : undefined - const blended = [ - mix(startRgb.r, endRgb.r), - mix(startRgb.g, endRgb.g), - mix(startRgb.b, endRgb.b), - ] - .map((value) => value.toString(16).padStart(2, '0')) - .join('') - - return `#${blended}` - } - - return fallback -} - -const copyThemeWithAutoText = ( - baseTheme: ChatTheme, - themeName: 'dark' | 'light', - background: [number, number, number] | null, -): ChatTheme => { - const autoTextColor = resolveAutoTextColor(baseTheme, themeName, background) - const markdown = baseTheme.markdown - ? { - ...baseTheme.markdown, - inlineCodeFg: autoTextColor, - codeTextFg: autoTextColor, - blockquoteTextFg: autoTextColor, - } - : undefined - - return { - ...baseTheme, - messageAiText: autoTextColor, - messageUserText: autoTextColor, - inputFg: autoTextColor, - inputFocusedFg: autoTextColor, - agentText: autoTextColor, - agentContentText: autoTextColor, - chromeText: - themeName === 'dark' && autoTextColor === '#f8fafc' - ? '#f8fafc' - : themeName === 'light' && autoTextColor === '#111827' - ? '#111827' - : baseTheme.chromeText, - markdown, - } +export const chatTheme: ChatTheme = { + ...baseTheme, + markdown, } -export const chatTheme: ChatTheme = copyThemeWithAutoText( - BASE_THEMES[resolvedThemeName], - resolvedThemeName, - detectedTerminalBackground, -) - export const createMarkdownPalette = (theme: ChatTheme): MarkdownPalette => { const headingDefaults: Record = { 1: theme.statusAccent, From 79d5abc602e8b5c291e8b9f0ac14a043d5c5e953 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 02:14:18 -0700 Subject: [PATCH 06/41] Revert "Add toggle open flag and tighten collapse spacing" --- cli/src/chat.tsx | 11 +++-------- cli/src/index.tsx | 16 +--------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 434bbb529..96d87035f 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -100,7 +100,6 @@ export const App = ({ requireAuth, hasInvalidCredentials, loadedAgentsData, - initialToggleState, }: { initialPrompt: string | null agentId?: string @@ -110,7 +109,6 @@ export const App = ({ agents: Array<{ id: string; displayName: string }> agentsDir: string } | null - initialToggleState: 'open' | 'closed' | null }) => { const renderer = useRenderer() const { width: measuredWidth } = useTerminalDimensions() @@ -130,7 +128,6 @@ export const App = ({ const theme = chatTheme const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) - const shouldCollapseByDefault = initialToggleState !== 'open' const [exitWarning, setExitWarning] = useState(null) const exitArmedRef = useRef(false) @@ -241,13 +238,11 @@ export const App = ({ timestamp: new Date().toISOString(), } - // Set as collapsed by default unless forced open - if (shouldCollapseByDefault) { - setCollapsedAgents((prev) => new Set([...prev, agentListId])) - } + // Set as collapsed by default + setCollapsedAgents((prev) => new Set([...prev, agentListId])) setMessages([initialMessage]) } - }, [loadedAgentsData, theme, shouldCollapseByDefault]) // Only run when loadedAgentsData changes + }, [loadedAgentsData, theme]) // Only run when loadedAgentsData changes const { inputValue, diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 3133744d1..3b142d4d8 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -37,7 +37,6 @@ type ParsedArgs = { initialPrompt: string | null agent?: string clearLogs: boolean - toggleState: 'open' | 'closed' | null } function parseArgs(): ParsedArgs { @@ -52,10 +51,6 @@ function parseArgs(): ParsedArgs { 'Specify which agent to use (e.g., "base", "ask", "file-picker")', ) .option('--clear-logs', 'Remove any existing CLI log files before starting') - .option( - '--toggle ', - 'Force initial toggle state (open | closed)', - ) .helpOption('-h, --help', 'Show this help message') .argument('[prompt...]', 'Initial prompt to send to the agent') .allowExcessArguments(true) @@ -68,18 +63,10 @@ function parseArgs(): ParsedArgs { initialPrompt: args.length > 0 ? args.join(' ') : null, agent: options.agent, clearLogs: options.clearLogs || false, - toggleState: - typeof options.toggle === 'string' - ? options.toggle.trim().toLowerCase() === 'open' - ? 'open' - : options.toggle.trim().toLowerCase() === 'closed' - ? 'closed' - : null - : null, } } -const { initialPrompt, agent, clearLogs, toggleState } = parseArgs() +const { initialPrompt, agent, clearLogs } = parseArgs() if (clearLogs) { clearLogFile() @@ -135,7 +122,6 @@ const AppWithAsyncAuth = () => { requireAuth={requireAuth} hasInvalidCredentials={hasInvalidCredentials} loadedAgentsData={loadedAgentsData} - initialToggleState={toggleState} /> ) } From 660736b5630c7359a3666890a5d8e3b760905ec5 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 02:29:40 -0700 Subject: [PATCH 07/41] feat(cli): add customizable tool rendering system - Create tool-renderer component for custom tool display logic - Add special rendering for list_directory with path and file summaries - Store raw tool output alongside formatted output - Add title accessory support to ToolItem component - Enable custom collapsed previews and content rendering - Add tool name display overrides for better UX --- cli/src/chat.tsx | 1 + cli/src/components/message-block.tsx | 21 +++- cli/src/components/tool-item.tsx | 12 ++ cli/src/components/tool-renderer.tsx | 164 +++++++++++++++++++++++++++ cli/src/hooks/use-send-message.ts | 6 +- cli/src/utils/codebuff-client.ts | 6 +- 6 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 cli/src/components/tool-renderer.tsx diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 96d87035f..61d44b391 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -61,6 +61,7 @@ export type ContentBlock = toolName: ToolName input: any output?: string + outputRaw?: unknown agentId?: string } | { diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 9ab6b9854..49eb60958 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -5,6 +5,7 @@ import { pluralize } from '@codebuff/common/util/string' import { BranchItem } from './branch-item' import { ToolItem } from './tool-item' +import { getToolRenderConfig } from './tool-renderer' import { getToolDisplayInfo } from '../utils/codebuff-client' import { renderMarkdown, @@ -92,6 +93,13 @@ export const MessageBlock = ({ const displayInfo = getToolDisplayInfo(toolBlock.toolName) const isCollapsed = collapsedAgents.has(toolBlock.toolCallId) const isStreaming = streamingAgents.has(toolBlock.toolCallId) + const indentationOffset = indentLevel * 2 + + const { titleAccessory, content: customContent, collapsedPreview } = + getToolRenderConfig(toolBlock, theme, { + availableWidth, + indentationOffset, + }) const inputContent = `\`\`\`json\n${JSON.stringify(toolBlock.input, null, 2)}\n\`\`\`` const codeBlockLang = @@ -137,11 +145,15 @@ export const MessageBlock = ({ } const agentMarkdownOptions = getAgentMarkdownOptions(indentLevel) - const displayContent = hasMarkdown(fullContent) - ? renderMarkdown(fullContent, agentMarkdownOptions) - : fullContent + const displayContent = + customContent ?? + (hasMarkdown(fullContent) + ? renderMarkdown(fullContent, agentMarkdownOptions) + : fullContent) - const indentationOffset = indentLevel * 2 + if (!isStreaming && isCollapsed && collapsedPreview) { + finishedPreview = collapsedPreview + } return ( { export const ToolItem = ({ name, + titleAccessory, content, isCollapsed, isStreaming, @@ -68,6 +70,10 @@ export const ToolItem = ({ const toggleColor = theme.statusSecondary const toggleIcon = isCollapsed ? '▸' : '▾' const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount + const hasTitleAccessory = + titleAccessory !== undefined && + titleAccessory !== null && + !(typeof titleAccessory === 'string' && titleAccessory.length === 0) return ( @@ -87,6 +93,12 @@ export const ToolItem = ({ {name} + {hasTitleAccessory ? ( + <> + {' '} + {titleAccessory} + + ) : null} {isCollapsed ? ( diff --git a/cli/src/components/tool-renderer.tsx b/cli/src/components/tool-renderer.tsx new file mode 100644 index 000000000..38f500bd1 --- /dev/null +++ b/cli/src/components/tool-renderer.tsx @@ -0,0 +1,164 @@ +import { TextAttributes } from '@opentui/core' +import React from 'react' +import stringWidth from 'string-width' + +import type { ContentBlock } from '../chat' +import type { ChatTheme } from '../utils/theme-system' + +type ToolBlock = Extract + +export type ToolRenderConfig = { + titleAccessory?: React.ReactNode + content?: React.ReactNode + collapsedPreview?: string +} + +export type ToolRenderOptions = { + availableWidth: number + indentationOffset: number +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} + +const extractPath = (toolBlock: ToolBlock, resultValue: unknown): string | null => { + if (isRecord(toolBlock.input) && typeof toolBlock.input.path === 'string') { + const trimmed = toolBlock.input.path.trim() + if (trimmed.length > 0) { + return trimmed + } + } + + if (isRecord(resultValue) && typeof resultValue.path === 'string') { + const trimmed = resultValue.path.trim() + if (trimmed.length > 0) { + return trimmed + } + } + + return null +} + +const summarizeFiles = ( + entries: unknown, + maxItems: number, + options: ToolRenderOptions, +): string | null => { + const maxWidth = Math.max( + 20, + options.availableWidth - options.indentationOffset - 6, + ) + + if (!Array.isArray(entries) || entries.length === 0) { + return null + } + + const validNames = entries + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + + if (validNames.length === 0) { + return null + } + + const summaryNames: string[] = [] + let widthUsed = 0 + + for (let index = 0; index < validNames.length; index += 1) { + if (summaryNames.length >= maxItems) { + break + } + + const name = validNames[index] + const prefix = summaryNames.length === 0 ? '' : ', ' + const candidate = `${prefix}${name}` + const candidateWidth = stringWidth(candidate) + const wouldExceedWidth = widthUsed + candidateWidth > maxWidth + + if (summaryNames.length > 0 && wouldExceedWidth) { + break + } + + summaryNames.push(name) + widthUsed += candidateWidth + + if (summaryNames.length === 1 && wouldExceedWidth) { + break + } + } + + if (summaryNames.length === 0) { + summaryNames.push(validNames[0]) + } + + const hasMore = summaryNames.length < validNames.length + const summary = summaryNames.join(', ') + return hasMore ? `${summary}, ...` : summary +} + +const getListDirectoryRender = ( + toolBlock: ToolBlock, + theme: ChatTheme, + options: ToolRenderOptions, +): ToolRenderConfig => { + const MAX_ITEMS = 3 + const resultValue = Array.isArray(toolBlock.outputRaw) + ? (toolBlock.outputRaw[0] as any)?.value + : undefined + + if (!isRecord(resultValue)) { + return {} + } + + const filesLine = summarizeFiles(resultValue.files, MAX_ITEMS, options) + const fallbackLine = filesLine + ? null + : summarizeFiles(resultValue.directories, MAX_ITEMS, options) + const path = extractPath(toolBlock, resultValue) + + const summaryLine = filesLine ?? fallbackLine + + if (!summaryLine && !path) { + return {} + } + + const content = + summaryLine !== null ? ( + + {summaryLine} + + ) : null + + const collapsedPreview = summaryLine ?? undefined + + const titleAccessory = path ? ( + + {path} + + ) : undefined + + return { + titleAccessory, + content, + collapsedPreview, + } +} + +export const getToolRenderConfig = ( + toolBlock: ToolBlock, + theme: ChatTheme, + options: ToolRenderOptions, +): ToolRenderConfig => { + switch (toolBlock.toolName) { + case 'list_directory': + return getListDirectoryRender(toolBlock, theme, options) + default: + return {} + } +} diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 1b0d6d446..0d7298cd7 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -1105,6 +1105,7 @@ export const useSendMessage = ({ toolName, input, agentId, + outputRaw: undefined, } return { @@ -1134,6 +1135,7 @@ export const useSendMessage = ({ toolName, input, agentId, + outputRaw: undefined, } return { @@ -1247,6 +1249,8 @@ export const useSendMessage = ({ const updateToolBlock = ( blocks: ContentBlock[], ): ContentBlock[] => { + const rawOutput = event.output + return blocks.map((block) => { if ( block.type === 'tool' && @@ -1265,7 +1269,7 @@ export const useSendMessage = ({ } else { output = formatToolOutput(event.output) } - return { ...block, output } + return { ...block, output, outputRaw: rawOutput } } else if (block.type === 'agent' && block.blocks) { return { ...block, blocks: updateToolBlock(block.blocks) } } diff --git a/cli/src/utils/codebuff-client.ts b/cli/src/utils/codebuff-client.ts index 2b615bf55..5e0bb1b57 100644 --- a/cli/src/utils/codebuff-client.ts +++ b/cli/src/utils/codebuff-client.ts @@ -41,12 +41,16 @@ export function getToolDisplayInfo(toolName: string): { name: string type: string } { + const TOOL_NAME_OVERRIDES: Record = { + list_directory: 'List Directories', + } + const capitalizeWords = (str: string) => { return str.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } return { - name: capitalizeWords(toolName), + name: TOOL_NAME_OVERRIDES[toolName] ?? capitalizeWords(toolName), type: 'tool', } } From 0274a48eb4b831ced56fceefc243dd1d83731ddf Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 02:55:54 -0700 Subject: [PATCH 08/41] Style CLI tool branches like tree --- cli/src/components/message-block.tsx | 59 +++++++++--- cli/src/components/tool-item.tsx | 128 +++++++++++++++++++-------- 2 files changed, 138 insertions(+), 49 deletions(-) diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 49eb60958..9566d2459 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -4,7 +4,7 @@ import React, { type ReactNode } from 'react' import { pluralize } from '@codebuff/common/util/string' import { BranchItem } from './branch-item' -import { ToolItem } from './tool-item' +import { ToolItem, type ToolBranchMeta } from './tool-item' import { getToolRenderConfig } from './tool-renderer' import { getToolDisplayInfo } from '../utils/codebuff-client' import { @@ -81,10 +81,33 @@ export const MessageBlock = ({ } } + const defaultToolBranchMeta: ToolBranchMeta = { + hasPrevious: false, + hasNext: false, + } + + const buildToolBranchMeta = ( + items: Array, + ): Map => { + const toolIndices = items + .map((block, index) => (block && block.type === 'tool' ? index : -1)) + .filter((index) => index !== -1) as number[] + + const meta = new Map() + toolIndices.forEach((blockIndex, position) => { + meta.set(blockIndex, { + hasPrevious: position > 0, + hasNext: position < toolIndices.length - 1, + }) + }) + return meta + } + const renderToolBranch = ( toolBlock: Extract, indentLevel: number, keyPrefix: string, + branchMeta: ToolBranchMeta, ): React.ReactNode => { if (toolBlock.toolName === 'end_turn') { return null @@ -170,6 +193,7 @@ export const MessageBlock = ({ streamingPreview={streamingPreview} finishedPreview={finishedPreview} theme={theme} + branchMeta={branchMeta} onToggle={() => onToggleCollapsed(toolBlock.toolCallId)} /> @@ -333,6 +357,7 @@ export const MessageBlock = ({ ): React.ReactNode[] { const nestedBlocks = agentBlock.blocks ?? [] const nodes: React.ReactNode[] = [] + const toolBranchMetaMap = buildToolBranchMeta(nestedBlocks) nestedBlocks.forEach((nestedBlock, nestedIdx) => { if (nestedBlock.type === 'text') { @@ -364,11 +389,14 @@ export const MessageBlock = ({ , ) } else if (nestedBlock.type === 'tool') { + const branchMeta = + toolBranchMetaMap.get(nestedIdx) ?? defaultToolBranchMeta nodes.push( renderToolBranch( nestedBlock, indentLevel, `${keyPrefix}-tool-${nestedBlock.toolCallId}`, + branchMeta, ), ) } else if (nestedBlock.type === 'agent') { @@ -385,6 +413,8 @@ export const MessageBlock = ({ return nodes } + const topLevelToolMeta = blocks ? buildToolBranchMeta(blocks) : null + return ( <> {isUser && ( @@ -422,18 +452,21 @@ export const MessageBlock = ({ ? 0 : 0 const blockTextColor = block.color ?? textColor - return ( - - {renderedContent} - - ) - } else if (block.type === 'tool') { - return renderToolBranch( - block, - 0, - `${messageId}-tool-${block.toolCallId}`, - ) - } else if (block.type === 'agent') { + return ( + + {renderedContent} + + ) + } else if (block.type === 'tool') { + const branchMeta = + topLevelToolMeta?.get(idx) ?? defaultToolBranchMeta + return renderToolBranch( + block, + 0, + `${messageId}-tool-${block.toolCallId}`, + branchMeta, + ) + } else if (block.type === 'agent') { return renderAgentBranch( block, 0, diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index db5b1a82e..3877eef27 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -3,6 +3,11 @@ import React, { type ReactNode } from 'react' import type { ChatTheme } from '../utils/theme-system' +export interface ToolBranchMeta { + hasPrevious: boolean + hasNext: boolean +} + interface ToolItemProps { name: string titleAccessory?: ReactNode @@ -12,6 +17,7 @@ interface ToolItemProps { streamingPreview: string finishedPreview: string theme: ChatTheme + branchMeta: ToolBranchMeta onToggle: () => void } @@ -65,18 +71,93 @@ export const ToolItem = ({ streamingPreview, finishedPreview, theme, + branchMeta, onToggle, }: ToolItemProps) => { - const toggleColor = theme.statusSecondary - const toggleIcon = isCollapsed ? '▸' : '▾' + const branchColor = theme.agentResponseCount + const branchAttributes = TextAttributes.DIM + const titleColor = theme.statusSecondary const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount + const connectorSymbol = branchMeta.hasNext ? '├' : '└' + const continuationPrefix = branchMeta.hasNext ? '│ ' : ' ' + const showBranchAbove = branchMeta.hasPrevious const hasTitleAccessory = - titleAccessory !== undefined && - titleAccessory !== null && - !(typeof titleAccessory === 'string' && titleAccessory.length === 0) + titleAccessory !== undefined && titleAccessory !== null + + const renderBranchSpacer = () => { + if (!showBranchAbove) { + return null + } + + return ( + + + + │ + + + + ) + } + + const renderConnectedSection = (node: ReactNode) => { + if (!node) { + return null + } + + return ( + + + + {continuationPrefix} + + + + {node} + + + ) + } + + const renderedContent = renderContent(content, theme) + const previewText = isStreaming ? streamingPreview : finishedPreview + const hasPreview = + typeof previewText === 'string' ? previewText.length > 0 : false + const previewNode = hasPreview ? ( + + {previewText} + + ) : null return ( + {renderBranchSpacer()} - {toggleIcon} - + + {connectorSymbol}{' '} + + {name} {hasTitleAccessory ? ( @@ -101,35 +184,8 @@ export const ToolItem = ({ ) : null} - {isCollapsed ? ( - (isStreaming && streamingPreview) || (!isStreaming && finishedPreview) ? ( - - - {isStreaming ? streamingPreview : finishedPreview} - - - ) : null - ) : ( - - {renderContent(content, theme)} - - )} + {isCollapsed ? renderConnectedSection(previewNode) : null} + {!isCollapsed ? renderConnectedSection(renderedContent) : null} ) } From df3593ea33363c8d6a08b42d8027f86ded4371c1 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 16:27:07 -0700 Subject: [PATCH 09/41] fix(cli): improve input text visibility and mode toggle colors - Set explicit input text colors for light/dark modes instead of 'default' - Dark mode: #e2e8f0 (brighter slate), Light mode: #334155 (darker slate) - Remove hardcoded fallback color that was causing black text in dark mode - Add dedicated orange/red colors for FAST/MAX mode toggle - Simplify agent-mode-toggle to use new dedicated theme properties --- cli/src/components/agent-mode-toggle.tsx | 6 +- cli/src/components/branch-item.tsx | 116 ++++++++++++----------- cli/src/components/message-block.tsx | 1 + cli/src/components/multiline-input.tsx | 48 +++++++--- cli/src/components/raised-pill.tsx | 51 ++++++---- cli/src/components/shimmer-text.tsx | 2 +- cli/src/components/suggestion-menu.tsx | 26 ++++- cli/src/components/tool-item.tsx | 4 +- cli/src/components/tool-renderer.tsx | 7 +- cli/src/utils/markdown-renderer.tsx | 4 +- cli/src/utils/syntax-highlighter.tsx | 2 +- cli/src/utils/theme-system.ts | 32 +++++-- npm-app/src/cli-handlers/agents.ts | 17 +--- 13 files changed, 200 insertions(+), 116 deletions(-) diff --git a/cli/src/components/agent-mode-toggle.tsx b/cli/src/components/agent-mode-toggle.tsx index cce08bea0..39dc7bdbc 100644 --- a/cli/src/components/agent-mode-toggle.tsx +++ b/cli/src/components/agent-mode-toggle.tsx @@ -11,10 +11,8 @@ export const AgentModeToggle = ({ onToggle: () => void }) => { const isFast = mode === 'FAST' - const frameColor = isFast - ? theme.agentToggleHeaderBg - : theme.agentToggleExpandedBg - const textColor = frameColor + const frameColor = isFast ? theme.modeToggleFastBg : theme.modeToggleMaxBg + const textColor = isFast ? theme.modeToggleFastText : theme.modeToggleMaxText const label = isFast ? 'FAST' : '💪 MAX' return ( diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index fee667937..a281dc22f 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -44,13 +44,25 @@ export const BranchItem = ({ isStreaming, streamingPreview, finishedPreview, - availableWidth, statusLabel, statusColor, statusIndicator = '●', theme, onToggle, }: BranchItemProps) => { + const resolveFg = ( + color?: string, + fallback?: string, + ): string | undefined => { + if (color && color !== 'default') return color + if (fallback && fallback !== 'default') return fallback + return undefined + } + const fallbackTextColor = + resolveFg(theme.agentContentText) ?? + resolveFg(theme.chromeText) ?? + '#d1d5e5' + const isExpanded = !isCollapsed const toggleFrameColor = isExpanded ? theme.agentToggleExpandedBg @@ -62,10 +74,10 @@ export const BranchItem = ({ const toggleLabel = `${isCollapsed ? '▸' : '▾'} ` const collapseButtonFrame = theme.agentToggleExpandedBg const collapseButtonText = collapseButtonFrame - const separatorColor = theme.agentResponseCount - const innerContentWidth = Math.max(0, Math.floor(availableWidth) - 4) - const horizontalLine = - innerContentWidth > 0 ? '─'.repeat(innerContentWidth) : '' + const toggleFrameFg = resolveFg(toggleFrameColor, fallbackTextColor) + const toggleIconFg = resolveFg(toggleIconColor, fallbackTextColor) + const toggleLabelFg = resolveFg(toggleLabelColor, fallbackTextColor) + const headerFg = resolveFg(theme.agentToggleHeaderText, fallbackTextColor) const statusText = statusLabel && statusLabel.length > 0 ? statusIndicator === '✓' @@ -172,7 +184,7 @@ export const BranchItem = ({ - Prompt + Prompt {prompt} @@ -215,9 +227,11 @@ export const BranchItem = ({ onMouseDown={onToggle} > - {toggleLabel} + + {toggleLabel} + {name} @@ -252,55 +266,49 @@ export const BranchItem = ({ ) : null ) : ( - <> - {horizontalLine && ( - - - {horizontalLine} + + {prompt && ( + + Prompt + + {prompt} - - )} - - {prompt && ( - - Prompt - - {prompt} + {content && ( + + Response - {content && ( - - Response - - )} - - )} - {renderExpandedContent(content)} - - + )} + )} + {renderExpandedContent(content)} + + - + )} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 9566d2459..86a7c372c 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -193,6 +193,7 @@ export const MessageBlock = ({ streamingPreview={streamingPreview} finishedPreview={finishedPreview} theme={theme} + titleColor={textColor} branchMeta={branchMeta} onToggle={() => onToggleCollapsed(toolBlock.toolCallId)} /> diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index af16886cd..7db5a3432 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -88,6 +88,7 @@ interface MultilineInputProps { inputFocusedFg: string inputPlaceholder: string cursor: string + statusAccent: string } width: number } @@ -579,6 +580,33 @@ export const MultilineInput = forwardRef< maxHeight, ]) + const resolveFg = ( + color?: string, + fallback?: string, + ): string | undefined => { + if (color && color !== 'default') return color + if (fallback && fallback !== 'default') return fallback + return undefined + } + + const resolvedInputColor = resolveFg( + isPlaceholder + ? theme.inputPlaceholder + : focused + ? theme.inputFocusedFg ?? theme.inputFg + : theme.inputFg, + ) + + const textStyle: Record = { bg: 'transparent' } + if (resolvedInputColor) { + textStyle.fg = resolvedInputColor + } + if (isPlaceholder) { + textStyle.attributes = TextAttributes.DIM + } + + const cursorFg = resolveFg(theme.cursor, theme.statusAccent) + return ( - + {showCursor ? ( <> {beforeCursor} {shouldHighlight ? ( - + {activeChar === ' ' ? '\u00a0' : activeChar} ) : ( - + {CURSOR_CHAR} )} diff --git a/cli/src/components/raised-pill.tsx b/cli/src/components/raised-pill.tsx index 7b96f9854..92004e783 100644 --- a/cli/src/components/raised-pill.tsx +++ b/cli/src/components/raised-pill.tsx @@ -31,19 +31,30 @@ export const RaisedPill = ({ onPress, style, }: RaisedPillProps): React.ReactNode => { + const resolveFg = (color?: string): string | undefined => + color && color !== 'default' ? color : undefined + + const resolvedFrameColor = resolveFg(frameColor) + const resolvedTextColor = resolveFg(textColor) + const leftRightPadding = - padding > 0 ? [{ text: ' '.repeat(padding), fg: textColor }] : [] + padding > 0 + ? [{ text: ' '.repeat(padding), fg: resolvedTextColor }] + : [] - const normalizedSegments: Array<{ text: string; fg: string; attr?: number }> = - [ - ...leftRightPadding, - ...segments.map((segment) => ({ - text: segment.text, - fg: segment.fg ?? textColor, - attr: segment.attr, - })), - ...leftRightPadding, - ] + const normalizedSegments: Array<{ + text: string + fg?: string + attr?: number + }> = [ + ...leftRightPadding, + ...segments.map((segment) => ({ + text: segment.text, + fg: resolveFg(segment.fg ?? textColor), + attr: segment.attr, + })), + ...leftRightPadding, + ] const contentText = normalizedSegments.map((segment) => segment.text).join('') const contentWidth = Math.max(0, stringWidth(contentText)) @@ -60,24 +71,32 @@ export const RaisedPill = ({ onMouseDown={onPress} > - {`╭${horizontal}╮`} + {`╭${horizontal}╮`} - + + │ + {normalizedSegments.map((segment, idx) => ( {segment.text} ))} - + + │ + - {`╰${horizontal}╯`} + {`╰${horizontal}╯`} ) diff --git a/cli/src/components/shimmer-text.tsx b/cli/src/components/shimmer-text.tsx index 4379debfd..dba8d182f 100644 --- a/cli/src/components/shimmer-text.tsx +++ b/cli/src/components/shimmer-text.tsx @@ -163,7 +163,7 @@ export const ShimmerText = ({ const generateColors = (length: number, colorPalette: string[]): string[] => { if (length === 0) return [] if (colorPalette.length === 0) { - return Array.from({ length }, () => '#ffffff') + return Array.from({ length }, () => '#dbeafe') } if (colorPalette.length === 1) { return Array.from({ length }, () => colorPalette[0]) diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index bbf3a1ac0..ef5f50c70 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -23,6 +23,19 @@ export const SuggestionMenu = ({ maxVisible = 5, prefix = '/', }: SuggestionMenuProps) => { + const resolveFg = ( + color?: string, + fallback?: string, + ): string | undefined => { + if (color && color !== 'default') return color + if (fallback && fallback !== 'default') return fallback + return undefined + } + const fallbackTextColor = + resolveFg(theme.agentContentText) ?? resolveFg(theme.chromeText) ?? '#d1d5e5' + const fallbackDescriptionColor = + resolveFg(theme.timestampUser) ?? fallbackTextColor + if (items.length === 0) { return null } @@ -76,6 +89,11 @@ export const SuggestionMenu = ({ const descriptionColor = isSelected ? theme.statusAccent : theme.timestampUser + const textFg = resolveFg(textColor, fallbackTextColor) + const descriptionFg = resolveFg( + descriptionColor, + fallbackDescriptionColor, + ) return ( {effectivePrefix} {item.label} {padding} - {item.description} + + {item.description} + ) diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index 3877eef27..f708ac816 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -19,6 +19,7 @@ interface ToolItemProps { theme: ChatTheme branchMeta: ToolBranchMeta onToggle: () => void + titleColor?: string } const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { @@ -73,10 +74,11 @@ export const ToolItem = ({ theme, branchMeta, onToggle, + titleColor: customTitleColor, }: ToolItemProps) => { const branchColor = theme.agentResponseCount const branchAttributes = TextAttributes.DIM - const titleColor = theme.statusSecondary + const titleColor = customTitleColor ?? theme.statusSecondary const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount const connectorSymbol = branchMeta.hasNext ? '├' : '└' const continuationPrefix = branchMeta.hasNext ? '│ ' : ' ' diff --git a/cli/src/components/tool-renderer.tsx b/cli/src/components/tool-renderer.tsx index 38f500bd1..028bd15e4 100644 --- a/cli/src/components/tool-renderer.tsx +++ b/cli/src/components/tool-renderer.tsx @@ -124,10 +124,13 @@ const getListDirectoryRender = ( return {} } + const summaryColor = theme.agentContentText + const pathColor = theme.statusAccent + const content = summaryLine !== null ? ( @@ -138,7 +141,7 @@ const getListDirectoryRender = ( const collapsedPreview = summaryLine ?? undefined const titleAccessory = path ? ( - + {path} ) : undefined diff --git a/cli/src/utils/markdown-renderer.tsx b/cli/src/utils/markdown-renderer.tsx index 5d547b1d5..28da06b34 100644 --- a/cli/src/utils/markdown-renderer.tsx +++ b/cli/src/utils/markdown-renderer.tsx @@ -45,11 +45,11 @@ const defaultPalette: MarkdownPalette = { 5: 'green', 6: 'green', }, - listBulletFg: 'white', + listBulletFg: '#d9e2ff', blockquoteBorderFg: 'gray', blockquoteTextFg: 'gray', dividerFg: '#666', - codeTextFg: 'brightWhite', + codeTextFg: '#d1d5db', codeMonochrome: false, } diff --git a/cli/src/utils/syntax-highlighter.tsx b/cli/src/utils/syntax-highlighter.tsx index 057d8727f..07fd0f1dd 100644 --- a/cli/src/utils/syntax-highlighter.tsx +++ b/cli/src/utils/syntax-highlighter.tsx @@ -11,7 +11,7 @@ export function highlightCode( lang: string, options: HighlightOptions = {}, ): ReactNode { - const { fg = 'brightWhite' } = options + const { fg = '#d1d5db' } = options // For now, just return the code with basic styling // Can be enhanced later with actual syntax highlighting diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 62c7b6117..41c149ff6 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -39,6 +39,10 @@ export interface ChatTheme { agentToggleText: string agentToggleExpandedBg: string agentContentBg: string + modeToggleFastBg: string + modeToggleFastText: string + modeToggleMaxBg: string + modeToggleMaxText: string markdown?: { headingFg?: Partial> inlineCodeFg?: string @@ -71,23 +75,27 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { statusAccent: '#facc15', statusSecondary: '#d9e2ff', inputBg: 'transparent', - inputFg: '#ffffff', + inputFg: '#e2e8f0', inputFocusedBg: 'transparent', - inputFocusedFg: '#ffffff', - inputPlaceholder: '#cbd5f5', + inputFocusedFg: '#e2e8f0', + inputPlaceholder: 'default', cursor: '#22c55e', agentPrefix: '#22c55e', agentName: '#4ade80', - agentText: '#ffffff', + agentText: '#e2e8f0', agentCheckmark: '#22c55e', agentResponseCount: '#94a3b8', agentFocusedBg: 'transparent', agentContentText: '#e2e8f0', - agentToggleHeaderBg: '#475569', - agentToggleHeaderText: '#f8fafc', - agentToggleText: '#f8fafc', + agentToggleHeaderBg: 'default', + agentToggleHeaderText: 'default', + agentToggleText: 'default', agentToggleExpandedBg: '#047857', agentContentBg: 'transparent', + modeToggleFastBg: '#f97316', + modeToggleFastText: '#f97316', + modeToggleMaxBg: '#dc2626', + modeToggleMaxText: '#dc2626', markdown: { codeBackground: 'transparent', codeHeaderFg: '#d9e2ff', @@ -103,7 +111,7 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { }, listBulletFg: '#d9e2ff', blockquoteBorderFg: '#4b5563', - blockquoteTextFg: '#ffffff', + blockquoteTextFg: '#f1f5f9', dividerFg: '#334155', codeMonochrome: true, }, @@ -125,9 +133,9 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { statusAccent: '#f59e0b', statusSecondary: '#6b7280', inputBg: 'transparent', - inputFg: '#111827', + inputFg: '#334155', inputFocusedBg: 'transparent', - inputFocusedFg: '#000000', + inputFocusedFg: '#334155', inputPlaceholder: '#9ca3af', cursor: '#3b82f6', agentPrefix: '#059669', @@ -142,6 +150,10 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { agentToggleText: '#f8fafc', agentToggleExpandedBg: '#047857', agentContentBg: 'transparent', + modeToggleFastBg: '#f97316', + modeToggleFastText: '#f97316', + modeToggleMaxBg: '#dc2626', + modeToggleMaxText: '#dc2626', markdown: { codeBackground: 'transparent', codeHeaderFg: '#4b5563', diff --git a/npm-app/src/cli-handlers/agents.ts b/npm-app/src/cli-handlers/agents.ts index 5064c619d..b1a2eedc8 100644 --- a/npm-app/src/cli-handlers/agents.ts +++ b/npm-app/src/cli-handlers/agents.ts @@ -304,17 +304,15 @@ function buildAllContentLines() { const cleanDescription = agent.description ? agent.description.replace(/\u001b\[[0-9;]*m/g, '') : '' - const availableWidth = terminalWidth - 4 // Account for padding if (isSelected) { const headerWidth = Math.min(terminalWidth - 6, 60) lines.push(` ${cyan('┌' + '─'.repeat(headerWidth + 2) + '┐')}`) - // Right-aligned title with separator line - const titlePadding = Math.max(0, headerWidth - cleanName.length - 4) - const separatorLine = '─'.repeat(titlePadding) + // Title row inside the header box + const namePadding = Math.max(0, headerWidth - cleanName.length) lines.push( - ` ${cyan('│')} ${gray(separatorLine)} ${agent.name} ${cyan('│')}`, + ` ${cyan('│')} ${agent.name}${' '.repeat(namePadding)} ${cyan('│')}`, ) if (agent.description) { @@ -328,13 +326,8 @@ function buildAllContentLines() { } lines.push(` ${cyan('└' + '─'.repeat(headerWidth + 2) + '┘')}`) } else { - // Right-aligned title with separator line for unselected - const titlePadding = Math.max( - 0, - availableWidth - cleanName.length - 4, - ) - const separatorLine = gray('─'.repeat(titlePadding)) - lines.push(` ${separatorLine} ${agent.name}`) + // Title line when header is not selected + lines.push(` ${agent.name}`) if (agent.description) { lines.push(` ${agent.description}`) From b541cfdc85c80317d825e398681f94dc44bec66e Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 17:16:46 -0700 Subject: [PATCH 10/41] fix(cli): improve text rendering and user message styling - Make user message vertical line extend full height with calculated line count - Italicize user input text for better visual distinction - Fix markdown paragraph spacing (single newlines instead of double) - Add spacing between consecutive text blocks to prevent bleeding - Update userLine colors for better visibility in both themes --- cli/src/components/message-block.tsx | 10 ++++++---- cli/src/hooks/use-message-renderer.tsx | 17 ++++++++++------- cli/src/utils/markdown-renderer.tsx | 10 +++++----- cli/src/utils/theme-system.ts | 4 ++-- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 86a7c372c..026a49f7f 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -448,10 +448,11 @@ export const MessageBlock = ({ : rawContent const prevBlock = idx > 0 ? blocks[idx - 1] : null const marginTop = - prevBlock && - (prevBlock.type === 'tool' || prevBlock.type === 'agent') - ? 0 - : 0 + prevBlock && prevBlock.type === 'text' + ? 1 + : prevBlock && (prevBlock.type === 'tool' || prevBlock.type === 'agent') + ? 0 + : 0 const blockTextColor = block.color ?? textColor return ( @@ -497,6 +498,7 @@ export const MessageBlock = ({ {displayContent} diff --git a/cli/src/hooks/use-message-renderer.tsx b/cli/src/hooks/use-message-renderer.tsx index 5eebeddfd..a3783aa5c 100644 --- a/cli/src/hooks/use-message-renderer.tsx +++ b/cli/src/hooks/use-message-renderer.tsx @@ -280,6 +280,11 @@ export const useMessageRenderer = ( const hasAgentChildren = agentChildren.length > 0 const showVerticalLine = isUser + // Calculate height for vertical line + const contentLines = message.content ? message.content.split('\n').length : 1 + const verticalLineHeight = Math.max(1, contentLines + 1) // +1 for timestamp + const verticalLineText = Array(verticalLineHeight).fill('│').join('\n') + return ( + > + {verticalLineText} + nodeToPlainText(child).replace(/^/gm, '> ')) .join('') - return `${content}\n\n` + return `${content}\n` } case 'code': { const code = node as Code const header = code.lang ? `\`\`\`${code.lang}\n` : '```\n' - return `${header}${code.value}\n\`\`\`\n\n` + return `${header}${code.value}\n\`\`\`\n` } default: diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 41c149ff6..92d30a20d 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -66,7 +66,7 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { accentText: '#facc15', panelBg: 'transparent', aiLine: '#34d399', - userLine: '#38bdf8', + userLine: '#0ea5e9', timestampAi: '#4ade80', timestampUser: '#60a5fa', messageAiText: '#9aa5ce', @@ -124,7 +124,7 @@ const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { accentText: '#f59e0b', panelBg: 'transparent', aiLine: '#059669', - userLine: '#3b82f6', + userLine: '#0284c7', timestampAi: '#047857', timestampUser: '#2563eb', messageAiText: '#9aa5ce', From fb60dcbe55ffcbdd61d56495c84f9fd10a2c1720 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 17:22:13 -0700 Subject: [PATCH 11/41] fix(cli): clean up code redundancies and improve markdown rendering - Simplify marginTop ternary logic - Don't trim markdown content to preserve paragraph spacing - Only strip trailing newlines from markdown output, not all whitespace --- cli/src/components/message-block.tsx | 14 ++++++-------- cli/src/utils/markdown-renderer.tsx | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 026a49f7f..a62af32b7 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -437,22 +437,20 @@ export const MessageBlock = ({ {blocks.map((block, idx) => { if (block.type === 'text') { const isStreamingText = isLoading || !isComplete + const hasMarkdownContent = hasMarkdown(block.content) const rawContent = isStreamingText ? trimTrailingNewlines(block.content) - : block.content.trim() + : hasMarkdownContent + ? block.content + : block.content.trim() const renderKey = `${messageId}-text-${idx}` - const renderedContent = hasMarkdown(rawContent) + const renderedContent = hasMarkdownContent ? isStreamingText ? renderStreamingMarkdown(rawContent, markdownOptions) : renderMarkdown(rawContent, markdownOptions) : rawContent const prevBlock = idx > 0 ? blocks[idx - 1] : null - const marginTop = - prevBlock && prevBlock.type === 'text' - ? 1 - : prevBlock && (prevBlock.type === 'tool' || prevBlock.type === 'agent') - ? 0 - : 0 + const marginTop = prevBlock && prevBlock.type === 'text' ? 1 : 0 const blockTextColor = block.color ?? textColor return ( diff --git a/cli/src/utils/markdown-renderer.tsx b/cli/src/utils/markdown-renderer.tsx index 1135dc077..133f7b436 100644 --- a/cli/src/utils/markdown-renderer.tsx +++ b/cli/src/utils/markdown-renderer.tsx @@ -152,7 +152,7 @@ export function renderMarkdown( void palette // Keep signature compatibility for future color styling const ast = processor.parse(markdown) - const text = nodeToPlainText(ast).replace(/\s+$/g, '') + const text = nodeToPlainText(ast).replace(/\n+$/g, '') return text } catch (error) { logger.error(error, 'Failed to parse markdown') From edd6f2785f6e3f214b6d848b6b243df60ab4608c Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 29 Oct 2025 17:30:49 -0700 Subject: [PATCH 12/41] fix(cli): handle bold and italic formatting in markdown lists - Add handlers for strong and emphasis nodes in markdown renderer - Remove debug logging from message block rendering --- cli/src/utils/markdown-renderer.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/src/utils/markdown-renderer.tsx b/cli/src/utils/markdown-renderer.tsx index 133f7b436..176af3f73 100644 --- a/cli/src/utils/markdown-renderer.tsx +++ b/cli/src/utils/markdown-renderer.tsx @@ -7,12 +7,14 @@ import type { Blockquote, Code, Content, + Emphasis, Heading, InlineCode, List, ListItem, Paragraph, Root, + Strong, Text, } from 'mdast' @@ -96,6 +98,12 @@ function nodeToPlainText(node: Content | Root): string { case 'inlineCode': return `\`${(node as InlineCode).value}\`` + case 'strong': + return (node as Strong).children.map(nodeToPlainText).join('') + + case 'emphasis': + return (node as Emphasis).children.map(nodeToPlainText).join('') + case 'heading': { const heading = node as Heading const prefix = '#'.repeat(Math.max(1, Math.min(heading.depth, 6))) From 32ee36b56a6872a08f9d20a89f6ffde081ead88c Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 30 Oct 2025 15:09:00 -0700 Subject: [PATCH 13/41] Refine macOS terminal theming --- bun.lock | 20 +- cli/package.json | 4 +- cli/src/chat.tsx | 130 +++- cli/src/components/branch-item.tsx | 43 +- cli/src/components/login-modal.tsx | 8 +- cli/src/components/message-block.tsx | 63 +- cli/src/components/multiline-input.tsx | 24 +- cli/src/components/suggestion-menu.tsx | 4 +- cli/src/components/tool-item.tsx | 35 +- cli/src/components/tool-renderer.tsx | 12 +- cli/src/hooks/use-message-renderer.tsx | 56 +- cli/src/hooks/use-send-message.ts | 6 +- cli/src/index.tsx | 3 + cli/src/utils/theme-system.ts | 893 ++++++++++++++++++++----- 14 files changed, 1043 insertions(+), 258 deletions(-) diff --git a/bun.lock b/bun.lock index f24963291..459705bb8 100644 --- a/bun.lock +++ b/bun.lock @@ -84,8 +84,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "^0.1.31", - "@opentui/react": "^0.1.31", + "@opentui/core": "0.0.0-20251029-f23e92a5", + "@opentui/react": "0.0.0-20251029-f23e92a5", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", @@ -1023,21 +1023,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], - "@opentui/core": ["@opentui/core@0.1.31", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.31", "@opentui/core-darwin-x64": "0.1.31", "@opentui/core-linux-arm64": "0.1.31", "@opentui/core-linux-x64": "0.1.31", "@opentui/core-win32-arm64": "0.1.31", "@opentui/core-win32-x64": "0.1.31", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-Q6nL0WFkDDjl3mibdSPppOJbU5mr2f/0iC1+GvydiSvi/iv4CGxaTu6oPyUOK5BVv8ujWFzQ0sR7rc6yv7Jr+Q=="], + "@opentui/core": ["@opentui/core@0.0.0-20251029-f23e92a5", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251029-f23e92a5", "@opentui/core-darwin-x64": "0.0.0-20251029-f23e92a5", "@opentui/core-linux-arm64": "0.0.0-20251029-f23e92a5", "@opentui/core-linux-x64": "0.0.0-20251029-f23e92a5", "@opentui/core-win32-arm64": "0.0.0-20251029-f23e92a5", "@opentui/core-win32-x64": "0.0.0-20251029-f23e92a5", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-41YZpKAyEkCuFRi7X65rZ2BJh3yQPsRjLtAeVYlD7lvJhUZ1FhCAVq9CLIjash+zftTV+T51MVZkb+/hJnDeUg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.31", "", { "os": "darwin", "cpu": "arm64" }, "sha512-irsQW6XUAwJ5YkWH3OHrAD3LX7MN36RWkNQbUh2/pYCRUa4+bdsh6esFv7eXnDt/fUKAQ+tNtw/6jCo7I3TXMw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251029-f23e92a5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PImgZv+8+v/sXj7ssbvznBXz8VKtcVAyFcRqqUkjoOd/fK+rXdljMO+4ABJuSzN2nuPYbMYP1MLGPLNcZPc4rQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.31", "", { "os": "darwin", "cpu": "x64" }, "sha512-MDxfSloyrl/AzTIgUvEQm61MHSG753f8UzKdg+gZTzUHb7kWwpPfYrzFAVwN9AnURVUMKvTzoFBZ61UxOSIarw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251029-f23e92a5", "", { "os": "darwin", "cpu": "x64" }, "sha512-zAKlnBiBcBF1kpKkB7R4we+JaX6ERT21kRPHqZi/BeVWwOGrC/m0gvmTCAJZG1nfRHMVE7xs28ANq/qYqeM+gw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.31", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+/F3lIsn7aHTqugO5hvdHjwILs/p92P+lAGCK9iBkEX20gTk9dOc6IUpC8iy0eNUJyCjYAilkWtAVIbS+S47Q=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251029-f23e92a5", "", { "os": "linux", "cpu": "arm64" }, "sha512-vu9UFKuZ2ES3fs1zzcSY4W+S7zAabQsL0htZzNX1oEZxoSZsoOKiWLbuDF8eYp3k0kGswJDZYTKzdcC8x2rK8g=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.31", "", { "os": "linux", "cpu": "x64" }, "sha512-sjDrN4KIT305dycX5A50jNPCcf7nVLKGkJwY7g4x+eWuOItbRCfChr3CyniABDbUlJkPiB8/tvbM/7tID7mjqQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251029-f23e92a5", "", { "os": "linux", "cpu": "x64" }, "sha512-rAbAKKHyYjNdW6dqqL4D6QgV/rXS0ksOwslCRWZ5fp5V68th8XQ0pjs7QKFyza5zlmK9jw2/0qvOtuchYiGwfg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.31", "", { "os": "win32", "cpu": "arm64" }, "sha512-4xbr/a75YoskNj0c91RRvib5tV77WTZG4DQVgmSwi8osGIDGZnZjpx5nMYU25m9b7NSJW6+kGYzPy/FHwaZtjg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251029-f23e92a5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0CR/oVGPCWXLZXm8P6CYnfucMQnPhyoK55gZQoUZSxTLQovOivEVYP+rHzECztHQ3UP8oeNkhbxuv2ZB8awrCA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.31", "", { "os": "win32", "cpu": "x64" }, "sha512-LhyPfR5PuX6hY1LBteAUz5khO8hxV3rLnk2inGEDMffBUkrN2XW0+R635BIIFtq/tYFeTf0mzf+/DwvhiLcgbg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251029-f23e92a5", "", { "os": "win32", "cpu": "x64" }, "sha512-gIpKmue8IbydtxBBfUmC8VtYTkb8t8fs3A2h8CZ/1dJZdfV07ndj5OEPPOVVAqg+4MY+PYwn3cxroJcZ4QbzJQ=="], - "@opentui/react": ["@opentui/react@0.1.31", "", { "dependencies": { "@opentui/core": "0.1.31", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-VG+6PrhuKekHpzMSJlGFV76OiytP55RXMZLz3D4eq19/T6to1GTL97lYgZbsNgxwhl3uB9OY61pr2Jir6/CBkw=="], + "@opentui/react": ["@opentui/react@0.0.0-20251029-f23e92a5", "", { "dependencies": { "@opentui/core": "0.0.0-20251029-f23e92a5", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-wFA9l6WDjqcB/3N2Rwaxmyplaxks7iS8ZETK0StKN/B8SsyYLZQu8uM9JNGKDovruC0dUmlcwLloA00XaySK1w=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], diff --git a/cli/package.json b/cli/package.json index 8e0fd113e..02ec04bc5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -33,8 +33,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "^0.1.31", - "@opentui/react": "^0.1.31", + "@opentui/core": "0.0.0-20251029-f23e92a5", + "@opentui/react": "0.0.0-20251029-f23e92a5", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 61d44b391..cb7d387f1 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -31,7 +31,12 @@ import { formatQueuedPreview } from './utils/helpers' import { loadLocalAgents } from './utils/local-agent-registry' import { logger } from './utils/logger' import { buildMessageTree } from './utils/message-tree-utils' -import { chatTheme, createMarkdownPalette } from './utils/theme-system' +import { + chatTheme, + createMarkdownPalette, + onThemeChange, + type ChatTheme, +} from './utils/theme-system' import type { User } from './utils/auth' import type { ToolName } from '@codebuff/sdk' @@ -127,8 +132,47 @@ export const App = ({ const terminalWidth = resolvedTerminalWidth const separatorWidth = Math.max(1, Math.floor(terminalWidth) - 2) - const theme = chatTheme - const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) + const cloneTheme = (input: ChatTheme): ChatTheme => ({ + ...input, + markdown: input.markdown + ? { + ...input.markdown, + headingFg: input.markdown.headingFg + ? { ...input.markdown.headingFg } + : undefined, + } + : undefined, + }) + + const [theme, setTheme] = useState(() => cloneTheme(chatTheme)) + const [resolvedThemeName, setResolvedThemeName] = useState<'dark' | 'light'>( + chatTheme.messageTextAttributes ? 'dark' : 'light', + ) + + useEffect(() => { + const unsubscribe = onThemeChange((updatedTheme, meta) => { + const nextTheme = cloneTheme(updatedTheme) + setTheme(nextTheme) + setResolvedThemeName(meta.resolvedThemeName) + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug( + { + themeChange: { + source: meta.source, + resolvedThemeName: meta.resolvedThemeName, + }, + }, + 'Applied theme change in chat component', + ) + } + }) + return unsubscribe + }, []) + + const markdownPalette = useMemo( + () => createMarkdownPalette(theme), + [theme], + ) const [exitWarning, setExitWarning] = useState(null) const exitArmedRef = useRef(false) @@ -191,16 +235,27 @@ export const App = ({ ) }, []) - // Initialize with loaded agents message + // Initialize and update loaded agents message when theme changes useEffect(() => { - if (loadedAgentsData && messages.length === 0) { - const agentListId = 'loaded-agents-list' - const userCredentials = getUserCredentials() - const greeting = userCredentials?.name?.trim().length - ? `Welcome back, ${userCredentials.name.trim()}!` - : null - - const blocks: ContentBlock[] = [ + if (!loadedAgentsData) { + return + } + + const agentListId = 'loaded-agents-list' + const userCredentials = getUserCredentials() + const greeting = userCredentials?.name?.trim().length + ? `Welcome back, ${userCredentials.name.trim()}!` + : null + + const baseTextColor = + resolvedThemeName === 'dark' + ? '#ffffff' + : theme.chromeText && theme.chromeText !== 'default' + ? theme.chromeText + : theme.agentResponseCount + + const buildBlocks = (listId: string): ContentBlock[] => { + const result: ContentBlock[] = [ { type: 'text', content: '\n\n' + LOGO_BLOCK, @@ -209,41 +264,76 @@ export const App = ({ ] if (greeting) { - blocks.push({ + result.push({ type: 'text', content: greeting, - color: theme.agentResponseCount, + color: baseTextColor, }) } - blocks.push( + result.push( { type: 'text', content: 'Codebuff can read and write files in this repository, and run terminal commands to help you build.', - color: theme.agentResponseCount, + color: baseTextColor, }, { type: 'agent-list', - id: agentListId, + id: listId, agents: loadedAgentsData.agents, agentsDir: loadedAgentsData.agentsDir, }, ) + return result + } + + if (messages.length === 0) { + const initialBlocks = buildBlocks(agentListId) const initialMessage: ChatMessage = { id: `system-loaded-agents-${Date.now()}`, variant: 'ai', content: '', // Content is in the block - blocks, + blocks: initialBlocks, timestamp: new Date().toISOString(), } - // Set as collapsed by default setCollapsedAgents((prev) => new Set([...prev, agentListId])) setMessages([initialMessage]) + return } - }, [loadedAgentsData, theme]) // Only run when loadedAgentsData changes + + setMessages((prev) => { + if (prev.length === 0) { + return prev + } + + const [firstMessage, ...rest] = prev + if (!firstMessage.blocks) { + return prev + } + + const agentListBlock = firstMessage.blocks.find( + (block): block is Extract => + block.type === 'agent-list', + ) + + if (!agentListBlock) { + return prev + } + + const updatedBlocks = buildBlocks(agentListBlock.id) + + return [ + { + ...firstMessage, + blocks: updatedBlocks, + }, + ...rest, + ] + }) + }, [loadedAgentsData, resolvedThemeName, theme]) const { inputValue, diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index a281dc22f..8df8d3c34 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -51,8 +51,8 @@ export const BranchItem = ({ onToggle, }: BranchItemProps) => { const resolveFg = ( - color?: string, - fallback?: string, + color?: string | null, + fallback?: string | null, ): string | undefined => { if (color && color !== 'default') return color if (fallback && fallback !== 'default') return fallback @@ -63,14 +63,20 @@ export const BranchItem = ({ resolveFg(theme.chromeText) ?? '#d1d5e5' + const baseTextAttributes = theme.messageTextAttributes ?? 0 + const getAttributes = (extra: number = 0): number | undefined => { + const combined = baseTextAttributes | extra + return combined === 0 ? undefined : combined + } + const isExpanded = !isCollapsed const toggleFrameColor = isExpanded ? theme.agentToggleExpandedBg - : theme.agentToggleHeaderBg + : theme.agentResponseCount ?? theme.agentToggleHeaderBg const toggleIconColor = isStreaming ? theme.statusAccent - : toggleFrameColor - const toggleLabelColor = toggleFrameColor + : theme.chromeText ?? toggleFrameColor + const toggleLabelColor = theme.chromeText ?? toggleFrameColor const toggleLabel = `${isCollapsed ? '▸' : '▾'} ` const collapseButtonFrame = theme.agentToggleExpandedBg const collapseButtonText = collapseButtonFrame @@ -131,7 +137,11 @@ export const BranchItem = ({ if (isTextRenderable(value)) { return ( - + {value} ) @@ -209,7 +219,11 @@ export const BranchItem = ({ }} > Prompt - + {prompt} @@ -254,12 +268,15 @@ export const BranchItem = ({ paddingLeft: 1, paddingRight: 1, paddingTop: 0, - paddingBottom: 1, + paddingBottom: 1, }} > {isStreaming ? streamingPreview : finishedPreview} @@ -285,7 +302,11 @@ export const BranchItem = ({ }} > Prompt - + {prompt} {content && ( diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 2d303b274..053685e7b 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -32,7 +32,7 @@ import { copyTextToClipboard } from '../utils/clipboard' import { logger } from '../utils/logger' import type { User } from '../utils/auth' -import type { ChatTheme } from '../utils/theme-system' +import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' interface LoginModalProps { onLoginSuccess: (user: User) => void @@ -221,7 +221,9 @@ export const LoginModal = ({ } }, [hasOpenedBrowser, loginUrl, copyToClipboard]) - const logoColor = theme.chromeText + const logoColor = + resolveThemeColor(theme.chromeText, theme.statusAccent) ?? + theme.statusAccent // Use custom hook for sheen animation const { applySheenToChar } = useSheenAnimation({ @@ -357,7 +359,7 @@ export const LoginModal = ({ > - + {isNarrow ? 'Codebuff' : 'Codebuff CLI'} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index a62af32b7..9b1868984 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -15,7 +15,7 @@ import { } from '../utils/markdown-renderer' import type { ContentBlock } from '../chat' -import type { ChatTheme } from '../utils/theme-system' +import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' const trimTrailingNewlines = (value: string): string => value.replace(/[\r\n]+$/g, '') @@ -35,7 +35,8 @@ interface MessageBlockProps { completionTime?: string credits?: number theme: ChatTheme - textColor: string + textColor?: string + textAttributes?: number timestampColor: string markdownOptions: { codeBlockWidth: number; palette: MarkdownPalette } availableWidth: number @@ -59,6 +60,7 @@ export const MessageBlock = ({ credits, theme, textColor, + textAttributes, timestampColor, markdownOptions, availableWidth, @@ -70,13 +72,15 @@ export const MessageBlock = ({ }: MessageBlockProps): ReactNode => { const getAgentMarkdownOptions = (indentLevel: number) => { const indentationOffset = indentLevel * 2 + const agentTextColor = + resolveThemeColor(theme.agentText) ?? markdownPalette.inlineCodeFg return { codeBlockWidth: Math.max(10, availableWidth - 12 - indentationOffset), palette: { ...markdownPalette, - inlineCodeFg: theme.agentText, - codeTextFg: theme.agentText, + inlineCodeFg: agentTextColor, + codeTextFg: agentTextColor, }, } } @@ -306,7 +310,11 @@ export const MessageBlock = ({ {sortedAgents.map((agent, idx) => { const identifier = formatIdentifier(agent) return ( - + {` • ${identifier}`} ) @@ -378,13 +386,18 @@ export const MessageBlock = ({ ? renderStreamingMarkdown(rawNestedContent, markdownOptionsForLevel) : renderMarkdown(rawNestedContent, markdownOptionsForLevel) : rawNestedContent + const nestedTextColor = resolveThemeColor(theme.agentText) + const nestedTextStyle: Record = { + marginLeft: Math.max(0, indentLevel * 2), + } + if (nestedTextColor) { + nestedTextStyle.fg = nestedTextColor + } nodes.push( {renderedContent} , @@ -415,6 +428,10 @@ export const MessageBlock = ({ } const topLevelToolMeta = blocks ? buildToolBranchMeta(blocks) : null + const normalizedTextAttributes = + textAttributes !== undefined && textAttributes !== 0 + ? textAttributes + : undefined return ( <> @@ -451,12 +468,20 @@ export const MessageBlock = ({ : rawContent const prevBlock = idx > 0 ? blocks[idx - 1] : null const marginTop = prevBlock && prevBlock.type === 'text' ? 1 : 0 - const blockTextColor = block.color ?? textColor - return ( - - {renderedContent} - - ) + const blockTextColor = resolveThemeColor(block.color, textColor) + const blockStyle: Record = { marginTop } + if (blockTextColor) { + blockStyle.fg = blockTextColor + } + return ( + + {renderedContent} + + ) } else if (block.type === 'tool') { const branchMeta = topLevelToolMeta?.get(idx) ?? defaultToolBranchMeta @@ -495,8 +520,12 @@ export const MessageBlock = ({ return ( { + const base = isUser ? TextAttributes.ITALIC : 0 + const combined = (normalizedTextAttributes ?? 0) | base + return combined === 0 ? undefined : combined + })()} > {displayContent} diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 7db5a3432..e8fec6777 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -13,6 +13,7 @@ import { import { useOpentuiPaste } from '../hooks/use-opentui-paste' import type { PasteEvent, ScrollBoxRenderable } from '@opentui/core' +import type { ChatTheme } from '../utils/theme-system' // Helper functions for text manipulation function findLineStart(text: string, cursor: number): number { @@ -81,15 +82,16 @@ interface MultilineInputProps { placeholder?: string focused?: boolean maxHeight?: number - theme: { - inputBg: string - inputFocusedBg: string - inputFg: string - inputFocusedFg: string - inputPlaceholder: string - cursor: string - statusAccent: string - } + theme: Pick< + ChatTheme, + | 'inputBg' + | 'inputFocusedBg' + | 'inputFg' + | 'inputFocusedFg' + | 'inputPlaceholder' + | 'cursor' + | 'statusAccent' + > width: number } @@ -581,8 +583,8 @@ export const MultilineInput = forwardRef< ]) const resolveFg = ( - color?: string, - fallback?: string, + color?: string | null, + fallback?: string | null, ): string | undefined => { if (color && color !== 'default') return color if (fallback && fallback !== 'default') return fallback diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index ef5f50c70..f388748e7 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -24,8 +24,8 @@ export const SuggestionMenu = ({ prefix = '/', }: SuggestionMenuProps) => { const resolveFg = ( - color?: string, - fallback?: string, + color?: string | null, + fallback?: string | null, ): string | undefined => { if (color && color !== 'default') return color if (fallback && fallback !== 'default') return fallback diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index f708ac816..f8369e827 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -1,7 +1,7 @@ import { TextAttributes } from '@opentui/core' import React, { type ReactNode } from 'react' -import type { ChatTheme } from '../utils/theme-system' +import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' export interface ToolBranchMeta { hasPrevious: boolean @@ -23,6 +23,12 @@ interface ToolItemProps { } const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { + const contentFg = resolveThemeColor(theme.agentContentText) + const contentAttributes = + theme.messageTextAttributes !== undefined && theme.messageTextAttributes !== 0 + ? theme.messageTextAttributes + : undefined + if ( value === null || value === undefined || @@ -34,7 +40,11 @@ const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { if (typeof value === 'string' || typeof value === 'number') { return ( - + {value} ) @@ -57,7 +67,11 @@ const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { } return ( - + {value as any} ) @@ -79,7 +93,12 @@ export const ToolItem = ({ const branchColor = theme.agentResponseCount const branchAttributes = TextAttributes.DIM const titleColor = customTitleColor ?? theme.statusSecondary - const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount + const previewColor = + resolveThemeColor( + isStreaming ? theme.agentText : theme.agentResponseCount, + theme.agentResponseCount, + ) ?? theme.agentResponseCount + const baseTextAttributes = theme.messageTextAttributes ?? 0 const connectorSymbol = branchMeta.hasNext ? '├' : '└' const continuationPrefix = branchMeta.hasNext ? '│ ' : ' ' const showBranchAbove = branchMeta.hasPrevious @@ -152,7 +171,13 @@ export const ToolItem = ({ const hasPreview = typeof previewText === 'string' ? previewText.length > 0 : false const previewNode = hasPreview ? ( - + { + const combined = baseTextAttributes | TextAttributes.ITALIC + return combined === 0 ? undefined : combined + })()} + > {previewText} ) : null diff --git a/cli/src/components/tool-renderer.tsx b/cli/src/components/tool-renderer.tsx index 028bd15e4..440a474c9 100644 --- a/cli/src/components/tool-renderer.tsx +++ b/cli/src/components/tool-renderer.tsx @@ -3,7 +3,7 @@ import React from 'react' import stringWidth from 'string-width' import type { ContentBlock } from '../chat' -import type { ChatTheme } from '../utils/theme-system' +import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' type ToolBlock = Extract @@ -124,14 +124,20 @@ const getListDirectoryRender = ( return {} } - const summaryColor = theme.agentContentText + const summaryColor = + resolveThemeColor(theme.agentContentText) ?? theme.statusSecondary const pathColor = theme.statusAccent + const baseAttributes = theme.messageTextAttributes ?? 0 + const getAttributes = (extra: number = 0): number | undefined => { + const combined = baseAttributes | extra + return combined === 0 ? undefined : combined + } const content = summaryLine !== null ? ( {summaryLine} diff --git a/cli/src/hooks/use-message-renderer.tsx b/cli/src/hooks/use-message-renderer.tsx index a3783aa5c..73d915e75 100644 --- a/cli/src/hooks/use-message-renderer.tsx +++ b/cli/src/hooks/use-message-renderer.tsx @@ -25,7 +25,7 @@ import { import { getDescendantIds, getAncestorIds } from '../utils/message-tree-utils' import type { ChatMessage } from '../chat' -import type { ChatTheme } from '../utils/theme-system' +import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' interface UseMessageRendererProps { messages: ChatMessage[] @@ -103,10 +103,12 @@ export const useMessageRenderer = ( : `${statusIndicator} ${statusLabel}` const agentCodeBlockWidth = Math.max(10, availableWidth - 12) + const agentTextColor = + resolveThemeColor(theme.agentText) ?? markdownPalette.inlineCodeFg const agentPalette: MarkdownPalette = { ...markdownPalette, - inlineCodeFg: theme.agentText, - codeTextFg: theme.agentText, + inlineCodeFg: agentTextColor, + codeTextFg: agentTextColor, } const agentMarkdownOptions = { codeBlockWidth: agentCodeBlockWidth, @@ -204,7 +206,10 @@ export const useMessageRenderer = ( onMouseDown={handleContentClick} > {isStreaming && isCollapsed && streamingPreview && ( - + {streamingPreview} )} @@ -219,7 +224,7 @@ export const useMessageRenderer = ( {!isCollapsed && ( {displayContent} @@ -259,14 +264,18 @@ export const useMessageRenderer = ( const isAi = message.variant === 'ai' const isUser = message.variant === 'user' const lineColor = isAi ? theme.aiLine : theme.userLine - const textColor = isAi ? theme.messageAiText : theme.messageUserText + const textColor = resolveThemeColor( + isAi ? theme.messageAiText : theme.messageUserText, + ) + const textAttributes = + textColor === undefined ? theme.messageTextAttributes : undefined const timestampColor = isAi ? theme.timestampAi : theme.timestampUser const estimatedMessageWidth = availableWidth const codeBlockWidth = Math.max(10, estimatedMessageWidth - 8) const paletteForMessage: MarkdownPalette = { ...markdownPalette, - inlineCodeFg: textColor, - codeTextFg: textColor, + inlineCodeFg: textColor ?? markdownPalette.inlineCodeFg, + codeTextFg: textColor ?? markdownPalette.codeTextFg, } const markdownOptions = { codeBlockWidth, palette: paletteForMessage } @@ -334,21 +343,22 @@ export const useMessageRenderer = ( justifyContent: 'center', }} > - prev.map((msg) => { if (msg.id !== aiMessageId) { @@ -1042,7 +1044,7 @@ export const useSendMessage = ({ (_: any, index: number) => `${toolCallId}-${index}`, ) - spawnAgentIds.forEach((agentId) => { + spawnAgentIds.forEach((agentId: string) => { setStreamingAgents((prev) => new Set(prev).add(agentId)) rootLevelAgentsToCollapseRef.current.add(agentId) }) @@ -1050,7 +1052,7 @@ export const useSendMessage = ({ if (spawnAgentIds.length > 0) { setCollapsedAgents((prev) => { const next = new Set(prev) - spawnAgentIds.forEach((id) => next.delete(id)) + spawnAgentIds.forEach((id: string) => next.delete(id)) return next }) } diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 3b142d4d8..9473a0dda 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -132,6 +132,9 @@ function startApp() { , + { + backgroundColor: 'transparent', + }, ) } diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 92d30a20d..e5d9454a6 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -1,14 +1,40 @@ import fs from 'node:fs' import { execSync } from 'node:child_process' +import os from 'node:os' +import path from 'node:path' + +import { TextAttributes } from '@opentui/core' import type { MarkdownPalette } from './markdown-renderer' +import { EventEmitter } from 'events' +import { logger } from './logger' type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 +export type ThemeColor = string | null + +export const resolveThemeColor = ( + color?: ThemeColor, + fallback?: ThemeColor, +): string | undefined => { + if (typeof color === 'string') { + const normalized = color.trim().toLowerCase() + if (normalized.length > 0 && normalized !== 'default') { + return color + } + } + + if (fallback !== undefined) { + return resolveThemeColor(fallback) + } + + return undefined +} + export interface ChatTheme { background: string chromeBg: string - chromeText: string + chromeText: ThemeColor accentBg: string accentText: string panelBg: string @@ -16,27 +42,27 @@ export interface ChatTheme { userLine: string timestampAi: string timestampUser: string - messageAiText: string - messageUserText: string + messageAiText: ThemeColor + messageUserText: ThemeColor messageBg: string statusAccent: string statusSecondary: string inputBg: string - inputFg: string + inputFg: ThemeColor inputFocusedBg: string - inputFocusedFg: string - inputPlaceholder: string + inputFocusedFg: ThemeColor + inputPlaceholder: ThemeColor cursor: string agentPrefix: string agentName: string - agentText: string + agentText: ThemeColor agentCheckmark: string agentResponseCount: string agentFocusedBg: string - agentContentText: string + agentContentText: ThemeColor agentToggleHeaderBg: string - agentToggleHeaderText: string - agentToggleText: string + agentToggleHeaderText: ThemeColor + agentToggleText: ThemeColor agentToggleExpandedBg: string agentContentBg: string modeToggleFastBg: string @@ -55,127 +81,205 @@ export interface ChatTheme { codeTextFg?: string codeMonochrome?: boolean } + messageTextAttributes?: number } -const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { +const TEXT_NEUTRALS: Record<'dark' | 'light', { primary: string; secondary: string }> = { dark: { - background: 'transparent', - chromeBg: 'transparent', - chromeText: '#e2e8f0', - accentBg: 'transparent', - accentText: '#facc15', - panelBg: 'transparent', - aiLine: '#34d399', - userLine: '#0ea5e9', - timestampAi: '#4ade80', - timestampUser: '#60a5fa', - messageAiText: '#9aa5ce', - messageUserText: '#9aa5ce', - messageBg: 'transparent', - statusAccent: '#facc15', - statusSecondary: '#d9e2ff', - inputBg: 'transparent', - inputFg: '#e2e8f0', - inputFocusedBg: 'transparent', - inputFocusedFg: '#e2e8f0', - inputPlaceholder: 'default', - cursor: '#22c55e', - agentPrefix: '#22c55e', - agentName: '#4ade80', - agentText: '#e2e8f0', - agentCheckmark: '#22c55e', - agentResponseCount: '#94a3b8', - agentFocusedBg: 'transparent', - agentContentText: '#e2e8f0', - agentToggleHeaderBg: 'default', - agentToggleHeaderText: 'default', - agentToggleText: 'default', - agentToggleExpandedBg: '#047857', - agentContentBg: 'transparent', - modeToggleFastBg: '#f97316', - modeToggleFastText: '#f97316', - modeToggleMaxBg: '#dc2626', - modeToggleMaxText: '#dc2626', - markdown: { - codeBackground: 'transparent', - codeHeaderFg: '#d9e2ff', - inlineCodeFg: '#e2e8f0', - codeTextFg: '#e2e8f0', - headingFg: { - 1: '#facc15', - 2: '#facc15', - 3: '#facc15', - 4: '#facc15', - 5: '#facc15', - 6: '#facc15', - }, - listBulletFg: '#d9e2ff', - blockquoteBorderFg: '#4b5563', - blockquoteTextFg: '#f1f5f9', - dividerFg: '#334155', - codeMonochrome: true, - }, + primary: '#ffffff', + secondary: '#dbeafe', }, light: { - background: 'transparent', - chromeBg: 'transparent', - chromeText: '#334155', - accentBg: 'transparent', - accentText: '#f59e0b', - panelBg: 'transparent', - aiLine: '#059669', - userLine: '#0284c7', - timestampAi: '#047857', - timestampUser: '#2563eb', - messageAiText: '#9aa5ce', - messageUserText: '#9aa5ce', - messageBg: 'transparent', - statusAccent: '#f59e0b', - statusSecondary: '#6b7280', - inputBg: 'transparent', - inputFg: '#334155', - inputFocusedBg: 'transparent', - inputFocusedFg: '#334155', - inputPlaceholder: '#9ca3af', - cursor: '#3b82f6', - agentPrefix: '#059669', - agentName: '#047857', - agentText: '#1f2937', - agentCheckmark: '#059669', - agentResponseCount: '#64748b', - agentFocusedBg: 'transparent', - agentContentText: '#475569', - agentToggleHeaderBg: '#94a3b8', - agentToggleHeaderText: '#f8fafc', - agentToggleText: '#f8fafc', - agentToggleExpandedBg: '#047857', - agentContentBg: 'transparent', - modeToggleFastBg: '#f97316', - modeToggleFastText: '#f97316', - modeToggleMaxBg: '#dc2626', - modeToggleMaxText: '#dc2626', - markdown: { - codeBackground: 'transparent', - codeHeaderFg: '#4b5563', - inlineCodeFg: '#dc2626', - codeTextFg: '#475569', - headingFg: { - 1: '#dc2626', - 2: '#dc2626', - 3: '#dc2626', - 4: '#dc2626', - 5: '#dc2626', - 6: '#dc2626', - }, - listBulletFg: '#6b7280', - blockquoteBorderFg: '#d1d5db', - blockquoteTextFg: '#374151', - dividerFg: '#e5e7eb', - codeMonochrome: true, + primary: '#1f2937', + secondary: '#475569', + }, +} + +const IS_MAC_TERMINAL = + process.platform === 'darwin' && process.env.TERM_PROGRAM === 'Apple_Terminal' + +const NEUTRAL_THEME: ChatTheme = { + background: 'transparent', + chromeBg: 'transparent', + chromeText: null, + accentBg: 'transparent', + accentText: '#2563eb', + panelBg: 'transparent', + aiLine: '#2563eb', + userLine: '#22c55e', + timestampAi: '#2563eb', + timestampUser: '#0ea5e9', + messageAiText: null, + messageUserText: null, + messageBg: 'transparent', + statusAccent: '#2563eb', + statusSecondary: '#475569', + inputBg: 'transparent', + inputFg: null, + inputFocusedBg: 'transparent', + inputFocusedFg: null, + inputPlaceholder: '#94a3b8', + cursor: '#2563eb', + agentPrefix: '#2563eb', + agentName: '#0ea5e9', + agentText: null, + agentCheckmark: '#22c55e', + agentResponseCount: '#475569', + agentFocusedBg: 'transparent', + agentContentText: null, + agentToggleHeaderBg: 'transparent', + agentToggleHeaderText: null, + agentToggleText: null, + agentToggleExpandedBg: '#1d4ed8', + agentContentBg: 'transparent', + modeToggleFastBg: '#f97316', + modeToggleFastText: '#f97316', + modeToggleMaxBg: '#dc2626', + modeToggleMaxText: '#dc2626', + markdown: { + codeBackground: 'transparent', + codeHeaderFg: '#475569', + inlineCodeFg: '#2563eb', + codeTextFg: '#2563eb', + headingFg: { + 1: '#2563eb', + 2: '#2563eb', + 3: '#2563eb', + 4: '#2563eb', + 5: '#2563eb', + 6: '#2563eb', }, + listBulletFg: '#475569', + blockquoteBorderFg: '#94a3b8', + blockquoteTextFg: '#475569', + dividerFg: '#94a3b8', + codeMonochrome: true, }, } +const BASE_THEMES: Record<'dark' | 'light', ChatTheme> = { + dark: NEUTRAL_THEME, + light: NEUTRAL_THEME, +} + +const applyNeutralTextDefaults = ( + theme: ChatTheme, + mode: 'dark' | 'light', +): { theme: ChatTheme; allowTerminalDefaults: boolean } => { + const neutrals = TEXT_NEUTRALS[mode] + const allowTerminalDefaults = !IS_MAC_TERMINAL + + const resolveColor = ( + color: ThemeColor | undefined, + fallback: string, + allowDefault: boolean, + ): string => { + if (typeof color === 'string' && color !== 'default') { + return color + } + return allowDefault ? 'default' : fallback + } + + const adjustedTheme: ChatTheme = { + ...theme, + chromeText: theme.chromeText ?? neutrals.primary, + messageAiText: resolveColor( + theme.messageAiText, + neutrals.primary, + allowTerminalDefaults, + ), + messageUserText: resolveColor( + theme.messageUserText, + neutrals.primary, + allowTerminalDefaults, + ), + inputFg: resolveColor( + theme.inputFg, + neutrals.primary, + allowTerminalDefaults, + ), + inputFocusedFg: resolveColor( + theme.inputFocusedFg ?? theme.inputFg, + neutrals.primary, + allowTerminalDefaults, + ), + agentText: resolveColor( + theme.agentText, + neutrals.primary, + allowTerminalDefaults, + ), + agentContentText: resolveColor( + theme.agentContentText, + neutrals.secondary, + allowTerminalDefaults, + ), + agentToggleHeaderText: resolveColor( + theme.agentToggleHeaderText, + neutrals.primary, + allowTerminalDefaults, + ), + agentToggleText: resolveColor( + theme.agentToggleText, + neutrals.primary, + allowTerminalDefaults, + ), + } + + if (mode === 'dark') { + adjustedTheme.messageAiText = '#ffffff' + adjustedTheme.messageUserText = '#ffffff' + adjustedTheme.inputFg = '#ffffff' + adjustedTheme.inputFocusedFg = '#ffffff' + adjustedTheme.agentText = '#ffffff' + adjustedTheme.agentContentText = '#dbeafe' + adjustedTheme.agentToggleHeaderText = '#ffffff' + adjustedTheme.agentToggleText = '#ffffff' + adjustedTheme.timestampAi = DARK_VARIANT_OVERRIDES.timestampAi + adjustedTheme.timestampUser = DARK_VARIANT_OVERRIDES.timestampUser + adjustedTheme.aiLine = DARK_VARIANT_OVERRIDES.aiLine + adjustedTheme.userLine = DARK_VARIANT_OVERRIDES.userLine + adjustedTheme.statusSecondary = + theme.statusSecondary === NEUTRAL_THEME.statusSecondary + ? '#bfdbfe' + : theme.statusSecondary + adjustedTheme.agentResponseCount = + theme.agentResponseCount === NEUTRAL_THEME.agentResponseCount + ? '#bfdbfe' + : theme.agentResponseCount + adjustedTheme.timestampAi = '#c7d2fe' + adjustedTheme.timestampUser = '#bfdbfe' + adjustedTheme.aiLine = '#60a5fa' + adjustedTheme.userLine = '#38bdf8' + adjustedTheme.messageTextAttributes = + theme.messageTextAttributes ?? TextAttributes.BOLD + } else { + adjustedTheme.messageAiText = neutrals.primary + adjustedTheme.messageUserText = neutrals.primary + adjustedTheme.inputFg = neutrals.primary + adjustedTheme.inputFocusedFg = neutrals.primary + adjustedTheme.agentText = neutrals.primary + adjustedTheme.agentContentText = neutrals.secondary + adjustedTheme.agentToggleHeaderText = neutrals.primary + adjustedTheme.agentToggleText = neutrals.primary + adjustedTheme.timestampAi = LIGHT_VARIANT_OVERRIDES.timestampAi + adjustedTheme.timestampUser = LIGHT_VARIANT_OVERRIDES.timestampUser + adjustedTheme.aiLine = LIGHT_VARIANT_OVERRIDES.aiLine + adjustedTheme.userLine = LIGHT_VARIANT_OVERRIDES.userLine + adjustedTheme.messageTextAttributes = + theme.messageTextAttributes ?? undefined + } + + let finalTheme = adjustedTheme + if (IS_MAC_TERMINAL) { + finalTheme = mergeThemeOverrides( + adjustedTheme, + MAC_TERMINAL_THEME_OVERRIDES[mode], + ) + } + + return { theme: finalTheme, allowTerminalDefaults } +} + const getNormalizedEnvTheme = (): 'dark' | 'light' | null => { const raw = process.env.OPEN_TUI_THEME ?? process.env.OPENTUI_THEME if (!raw) return null @@ -387,6 +491,104 @@ const detectThemeFromTerminalBackground = (): 'dark' | 'light' | null => { } } +const DARK_VARIANT_OVERRIDES = { + timestampAi: '#c7d2fe', + timestampUser: '#bfdbfe', + aiLine: '#60a5fa', + userLine: '#38bdf8', +} + +const LIGHT_VARIANT_OVERRIDES = { + timestampAi: NEUTRAL_THEME.timestampAi, + timestampUser: NEUTRAL_THEME.timestampUser, + aiLine: NEUTRAL_THEME.aiLine, + userLine: NEUTRAL_THEME.userLine, +} + +const MAC_TERMINAL_THEME_OVERRIDES: Record<'dark' | 'light', Partial> = { + light: { + statusAccent: '#0f62fe', + statusSecondary: '#334155', + agentResponseCount: '#0f62fe', + agentPrefix: '#0f62fe', + agentName: '#0f172a', + agentText: '#1f2937', + agentContentText: '#334155', + agentToggleHeaderText: '#0f172a', + agentToggleText: '#0f172a', + chromeText: '#0f172a', + inputPlaceholder: '#64748b', + markdown: { + inlineCodeFg: '#0f62fe', + codeTextFg: '#0f172a', + headingFg: { + 1: '#0f62fe', + 2: '#0f62fe', + 3: '#0f62fe', + 4: '#0f62fe', + 5: '#0f62fe', + 6: '#0f62fe', + }, + }, + }, + dark: { + statusAccent: '#7dd3fc', + statusSecondary: '#dbeafe', + agentResponseCount: '#93c5fd', + agentPrefix: '#7dd3fc', + agentName: '#ffffff', + agentText: '#ffffff', + agentContentText: '#dbeafe', + agentToggleHeaderText: '#ffffff', + agentToggleText: '#ffffff', + chromeText: '#ffffff', + inputPlaceholder: '#cbd5f5', + markdown: { + inlineCodeFg: '#93c5fd', + codeTextFg: '#dbeafe', + headingFg: { + 1: '#93c5fd', + 2: '#93c5fd', + 3: '#93c5fd', + 4: '#93c5fd', + 5: '#93c5fd', + 6: '#93c5fd', + }, + }, + }, +} + +const mergeThemeOverrides = ( + base: ChatTheme, + overrides: Partial, +): ChatTheme => { + if (!overrides) return base + + const { markdown: markdownOverrides, ...rest } = overrides + const merged: ChatTheme = { + ...base, + ...rest, + } + + if (markdownOverrides) { + const baseMarkdown = base.markdown ?? {} + const headingOverrides = markdownOverrides.headingFg + const mergedMarkdown: NonNullable = { + ...baseMarkdown, + ...markdownOverrides, + } + if (headingOverrides) { + mergedMarkdown.headingFg = { + ...(baseMarkdown.headingFg ?? {}), + ...headingOverrides, + } + } + merged.markdown = mergedMarkdown + } + + return merged +} + const detectThemeFromSystemAppearance = (): 'dark' | 'light' | null => { if (process.platform !== 'darwin') return null try { @@ -405,29 +607,423 @@ const detectThemeFromSystemAppearance = (): 'dark' | 'light' | null => { return null } -const resolvedThemeName: 'dark' | 'light' = - getNormalizedEnvTheme() ?? - detectThemeFromColorFgbg() ?? - detectThemeFromTerminalBackground() ?? - detectThemeFromSystemAppearance() ?? - 'light' - -const baseTheme = BASE_THEMES[resolvedThemeName] -const markdown = baseTheme.markdown - ? { - ...baseTheme.markdown, - headingFg: baseTheme.markdown.headingFg - ? { ...baseTheme.markdown.headingFg } - : undefined, +const escapeRegex = (value: string): string => + value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + +const detectThemeFromMacTerminal = (): 'dark' | 'light' | null => { + if (process.platform !== 'darwin') return null + if (process.env.TERM_PROGRAM !== 'Apple_Terminal') return null + + const profile = + process.env.TERM_PROFILE || + (() => { + try { + return execSync("defaults read com.apple.Terminal 'Default Window Settings'", { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }).trim() + } catch { + return null + } + })() + if (!profile) { + return detectThemeFromSystemAppearance() + } + + try { + const rawSettings = execSync( + "defaults read com.apple.Terminal 'Window Settings'", + { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }, + ) + + const profilePattern = new RegExp( + `\"${escapeRegex(profile)}\"\\s*=\\s*\\{[^}]*?BackgroundColor\\s*=\\s*\\(([^)]*)\\)`, + 's', + ) + const match = rawSettings.match(profilePattern) + if (!match) return null + + const [r, g, b] = match[1] + .split(',') + .slice(0, 3) + .map((component) => Number.parseFloat(component.trim())) + if ( + [r, g, b].some( + (component) => Number.isNaN(component) || !Number.isFinite(component), + ) + ) { + return null } - : undefined -export const chatTheme: ChatTheme = { - ...baseTheme, - markdown, + const brightness = estimateBrightness([ + Math.round(Math.max(0, Math.min(1, r)) * 255), + Math.round(Math.max(0, Math.min(1, g)) * 255), + Math.round(Math.max(0, Math.min(1, b)) * 255), + ]) + + return brightness >= 160 ? 'light' : 'dark' + } catch { + return null + } +} + +type ThemeDetectionStep = { + name: string + detect: () => 'dark' | 'light' | null +} + +const THEME_DETECTION_STEPS: ThemeDetectionStep[] = [ + { + name: 'env', + detect: getNormalizedEnvTheme, + }, + { + name: 'colorFgbg', + detect: detectThemeFromColorFgbg, + }, + { + name: 'terminalBackground', + detect: detectThemeFromTerminalBackground, + }, + { + name: 'macTerminalProfileOrSystemFallback', + detect: detectThemeFromMacTerminal, + }, + { + name: 'systemAppearance', + detect: detectThemeFromSystemAppearance, + }, +] + +type ThemeDetectionRecord = { + name: string + value: 'dark' | 'light' | null +} + +interface ThemeComputationMeta { + theme: ChatTheme + resolvedThemeName: 'dark' | 'light' + allowTerminalDefaults: boolean + detectionTrail: ThemeDetectionRecord[] +} + +const computeTheme = (): ThemeComputationMeta => { + const detectionTrail: ThemeDetectionRecord[] = [] + let resolvedThemeName: 'dark' | 'light' | null = null + + for (const step of THEME_DETECTION_STEPS) { + const value = step.detect() + detectionTrail.push({ name: step.name, value }) + if (value) { + resolvedThemeName = value + break + } + } + + if (!resolvedThemeName) { + resolvedThemeName = 'light' + } + + const baseTheme = BASE_THEMES[resolvedThemeName] + const { theme: neutralizedTheme, allowTerminalDefaults } = + applyNeutralTextDefaults(baseTheme, resolvedThemeName) + const markdown = neutralizedTheme.markdown + ? { + ...neutralizedTheme.markdown, + headingFg: neutralizedTheme.markdown.headingFg + ? { ...neutralizedTheme.markdown.headingFg } + : undefined, + } + : undefined + + const theme: ChatTheme = { + ...neutralizedTheme, + markdown, + } + + return { + theme, + resolvedThemeName, + allowTerminalDefaults, + detectionTrail, + } +} + +const themeEmitter = new EventEmitter() + +let currentChatTheme: ChatTheme = NEUTRAL_THEME + +export const chatTheme = new Proxy(currentChatTheme, { + get(target, prop, receiver) { + return Reflect.get(target, prop, receiver) + }, + set(target, prop, value) { + Reflect.set(target, prop, value) + return true + }, +}) as ChatTheme + +const applyTheme = (meta: ThemeComputationMeta, source: string) => { + currentChatTheme = meta.theme + Object.assign(chatTheme, meta.theme) + themeEmitter.emit('theme-change', chatTheme, { + source, + resolvedThemeName: meta.resolvedThemeName, + }) + + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug( + { + themeDetection: { + isMacTerminal: IS_MAC_TERMINAL, + termProgram: process.env.TERM_PROGRAM ?? null, + termProfile: process.env.TERM_PROFILE ?? null, + resolvedThemeName: meta.resolvedThemeName, + allowTerminalDefaults: meta.allowTerminalDefaults, + detectionTrail: meta.detectionTrail, + source, + colors: { + messageAiText: chatTheme.messageAiText, + messageUserText: chatTheme.messageUserText, + inputFg: chatTheme.inputFg, + agentText: chatTheme.agentText, + agentContentText: chatTheme.agentContentText, + }, + messageTextAttributes: chatTheme.messageTextAttributes ?? null, + }, + }, + 'Resolved chat theme configuration', + ) + } +} + +const initialComputation = computeTheme() +applyTheme(initialComputation, 'initial') + +export const onThemeChange = ( + listener: ( + theme: ChatTheme, + meta: { source: string; resolvedThemeName: 'dark' | 'light' }, + ) => void, +) => { + themeEmitter.on('theme-change', listener) + return () => { + themeEmitter.off('theme-change', listener) + } +} + +const recomputeTheme = (source: string) => { + const meta = computeTheme() + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug( + { source, resolvedThemeName: meta.resolvedThemeName }, + 'Recomputing theme', + ) + } + applyTheme(meta, source) +} + +const pendingReasons = new Set() +let recomputeTimeout: NodeJS.Timeout | null = null + +const scheduleThemeRecompute = (reason: string, delay = 100) => { + pendingReasons.add(reason) + if (recomputeTimeout) { + clearTimeout(recomputeTimeout) + } + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug( + { reason, delay, pending: Array.from(pendingReasons) }, + 'Scheduling theme recompute', + ) + } + recomputeTimeout = setTimeout(() => { + recomputeTimeout = null + const combinedReason = Array.from(pendingReasons).join(',') + pendingReasons.clear() + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug( + { combinedReason }, + 'Executing scheduled theme recompute', + ) + } + recomputeTheme(combinedReason || reason) + }, delay) +} + +const macWatchers = new Map() +const macWatcherRetryTimers = new Map() + +const detachMacWatcher = (target: string) => { + const watcher = macWatchers.get(target) + if (!watcher) return + try { + watcher.close() + } catch { + // ignore close errors + } + macWatchers.delete(target) +} + +const scheduleMacWatcherRetry = (target: string, delay = 750) => { + const existing = macWatcherRetryTimers.get(target) + if (existing) { + clearTimeout(existing) + } + const timeout = setTimeout(() => { + macWatcherRetryTimers.delete(target) + attachMacWatcher(target) + }, delay) + macWatcherRetryTimers.set(target, timeout) +} + +const attachMacWatcher = (target: string) => { + detachMacWatcher(target) + + if (!fs.existsSync(target)) { + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug({ target }, 'Theme watcher target missing, scheduling retry') + } + scheduleMacWatcherRetry(target) + return + } + + try { + const watcher = fs.watch(target, { persistent: false }, (eventType) => { + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug( + { target, eventType }, + 'Theme watcher detected change, scheduling recompute', + ) + } + scheduleThemeRecompute( + `fs:${path.basename(target)}:${eventType}`, + 250, + ) + + if (eventType === 'rename') { + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug({ target }, 'Theme watcher received rename, reattaching') + } + scheduleMacWatcherRetry(target, 250) + } + }) + watcher.on('error', (error) => { + logger.debug( + { + themeWatcherError: + error instanceof Error ? error.message : String(error), + target, + }, + 'Theme watcher encountered an error', + ) + scheduleMacWatcherRetry(target) + }) + macWatchers.set(target, watcher) + if (process.env.CODEBUFF_THEME_DEBUG === '1') { + logger.debug({ target }, 'Theme watcher attached') + } + } catch (error) { + logger.debug( + { + themeWatcherError: + error instanceof Error ? error.message : String(error), + target, + }, + 'Failed to start theme watcher', + ) + scheduleMacWatcherRetry(target) + } +} + +const setupMacThemeWatchers = () => { + if (process.platform !== 'darwin') return + + const targets = [ + path.join(os.homedir(), 'Library/Preferences/.GlobalPreferences.plist'), + path.join(os.homedir(), 'Library/Preferences/com.apple.Terminal.plist'), + ] + + for (const target of targets) { + attachMacWatcher(target) + } +} + +if (process.platform === 'darwin') { + setupMacThemeWatchers() +} + +const POLL_INTERVAL_MS = Number.parseInt( + process.env.CODEBUFF_THEME_POLL_MS ?? '', + 10, +) || 5000 + +let pollInterval: NodeJS.Timeout | null = null +if (POLL_INTERVAL_MS > 0) { + pollInterval = setInterval(() => { + recomputeTheme('interval') + }, POLL_INTERVAL_MS) +} + +process.on('exit', () => { + for (const watcher of macWatchers.values()) { + try { + watcher.close() + } catch { + // ignore + } + } + macWatchers.clear() + for (const timer of macWatcherRetryTimers.values()) { + clearTimeout(timer) + } + macWatcherRetryTimers.clear() + if (pollInterval) { + clearInterval(pollInterval) + } +}) + +process.on('SIGUSR2', () => { + recomputeTheme('signal:SIGUSR2') +}) + +export const forceThemeRecompute = (reason = 'manual') => { + recomputeTheme(reason) } export const createMarkdownPalette = (theme: ChatTheme): MarkdownPalette => { + const inlineCodeFg = + resolveThemeColor(theme.markdown?.inlineCodeFg, theme.messageAiText) ?? + theme.statusAccent + const codeBackground = + resolveThemeColor(theme.markdown?.codeBackground, theme.messageBg) ?? + 'transparent' + const codeHeaderFg = + resolveThemeColor(theme.markdown?.codeHeaderFg, theme.statusSecondary) ?? + theme.statusSecondary + const listBulletFg = + resolveThemeColor(theme.markdown?.listBulletFg, theme.statusSecondary) ?? + theme.statusSecondary + const blockquoteBorderFg = + resolveThemeColor( + theme.markdown?.blockquoteBorderFg, + theme.statusSecondary, + ) ?? theme.statusSecondary + const blockquoteTextFg = + resolveThemeColor( + theme.markdown?.blockquoteTextFg, + theme.agentContentText, + ) ?? theme.statusSecondary + const dividerFg = + resolveThemeColor(theme.markdown?.dividerFg, theme.statusSecondary) ?? + theme.statusSecondary + const codeTextFg = + resolveThemeColor(theme.markdown?.codeTextFg, theme.agentContentText) ?? + inlineCodeFg + const headingDefaults: Record = { 1: theme.statusAccent, 2: theme.statusAccent, @@ -440,19 +1036,18 @@ export const createMarkdownPalette = (theme: ChatTheme): MarkdownPalette => { const overrides = theme.markdown?.headingFg ?? {} return { - inlineCodeFg: theme.markdown?.inlineCodeFg ?? theme.messageAiText, - codeBackground: theme.markdown?.codeBackground ?? theme.messageBg, - codeHeaderFg: theme.markdown?.codeHeaderFg ?? theme.statusSecondary, + inlineCodeFg, + codeBackground, + codeHeaderFg, headingFg: { ...headingDefaults, ...overrides, }, - listBulletFg: theme.markdown?.listBulletFg ?? theme.statusSecondary, - blockquoteBorderFg: - theme.markdown?.blockquoteBorderFg ?? theme.statusSecondary, - blockquoteTextFg: theme.markdown?.blockquoteTextFg ?? theme.messageAiText, - dividerFg: theme.markdown?.dividerFg ?? theme.statusSecondary, - codeTextFg: theme.markdown?.codeTextFg ?? theme.messageAiText, + listBulletFg, + blockquoteBorderFg, + blockquoteTextFg, + dividerFg, + codeTextFg, codeMonochrome: theme.markdown?.codeMonochrome ?? true, } } From 625349631d9844dc82773d3e65c1ab2c9f8980b0 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 30 Oct 2025 16:08:52 -0700 Subject: [PATCH 14/41] Upgrade @opentui packages to 0.1.31 --- bun.lock | 20 ++++++++++---------- cli/package.json | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 459705bb8..894de6b68 100644 --- a/bun.lock +++ b/bun.lock @@ -84,8 +84,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "0.0.0-20251029-f23e92a5", - "@opentui/react": "0.0.0-20251029-f23e92a5", + "@opentui/core": "0.1.31", + "@opentui/react": "0.1.31", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", @@ -1023,21 +1023,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], - "@opentui/core": ["@opentui/core@0.0.0-20251029-f23e92a5", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251029-f23e92a5", "@opentui/core-darwin-x64": "0.0.0-20251029-f23e92a5", "@opentui/core-linux-arm64": "0.0.0-20251029-f23e92a5", "@opentui/core-linux-x64": "0.0.0-20251029-f23e92a5", "@opentui/core-win32-arm64": "0.0.0-20251029-f23e92a5", "@opentui/core-win32-x64": "0.0.0-20251029-f23e92a5", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-41YZpKAyEkCuFRi7X65rZ2BJh3yQPsRjLtAeVYlD7lvJhUZ1FhCAVq9CLIjash+zftTV+T51MVZkb+/hJnDeUg=="], + "@opentui/core": ["@opentui/core@0.1.31", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.31", "@opentui/core-darwin-x64": "0.1.31", "@opentui/core-linux-arm64": "0.1.31", "@opentui/core-linux-x64": "0.1.31", "@opentui/core-win32-arm64": "0.1.31", "@opentui/core-win32-x64": "0.1.31", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-Q6nL0WFkDDjl3mibdSPppOJbU5mr2f/0iC1+GvydiSvi/iv4CGxaTu6oPyUOK5BVv8ujWFzQ0sR7rc6yv7Jr+Q=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251029-f23e92a5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PImgZv+8+v/sXj7ssbvznBXz8VKtcVAyFcRqqUkjoOd/fK+rXdljMO+4ABJuSzN2nuPYbMYP1MLGPLNcZPc4rQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.31", "", { "os": "darwin", "cpu": "arm64" }, "sha512-irsQW6XUAwJ5YkWH3OHrAD3LX7MN36RWkNQbUh2/pYCRUa4+bdsh6esFv7eXnDt/fUKAQ+tNtw/6jCo7I3TXMw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251029-f23e92a5", "", { "os": "darwin", "cpu": "x64" }, "sha512-zAKlnBiBcBF1kpKkB7R4we+JaX6ERT21kRPHqZi/BeVWwOGrC/m0gvmTCAJZG1nfRHMVE7xs28ANq/qYqeM+gw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.31", "", { "os": "darwin", "cpu": "x64" }, "sha512-MDxfSloyrl/AzTIgUvEQm61MHSG753f8UzKdg+gZTzUHb7kWwpPfYrzFAVwN9AnURVUMKvTzoFBZ61UxOSIarw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251029-f23e92a5", "", { "os": "linux", "cpu": "arm64" }, "sha512-vu9UFKuZ2ES3fs1zzcSY4W+S7zAabQsL0htZzNX1oEZxoSZsoOKiWLbuDF8eYp3k0kGswJDZYTKzdcC8x2rK8g=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.31", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+/F3lIsn7aHTqugO5hvdHjwILs/p92P+lAGCK9iBkEX20gTk9dOc6IUpC8iy0eNUJyCjYAilkWtAVIbS+S47Q=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251029-f23e92a5", "", { "os": "linux", "cpu": "x64" }, "sha512-rAbAKKHyYjNdW6dqqL4D6QgV/rXS0ksOwslCRWZ5fp5V68th8XQ0pjs7QKFyza5zlmK9jw2/0qvOtuchYiGwfg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.31", "", { "os": "linux", "cpu": "x64" }, "sha512-sjDrN4KIT305dycX5A50jNPCcf7nVLKGkJwY7g4x+eWuOItbRCfChr3CyniABDbUlJkPiB8/tvbM/7tID7mjqQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251029-f23e92a5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0CR/oVGPCWXLZXm8P6CYnfucMQnPhyoK55gZQoUZSxTLQovOivEVYP+rHzECztHQ3UP8oeNkhbxuv2ZB8awrCA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.31", "", { "os": "win32", "cpu": "arm64" }, "sha512-4xbr/a75YoskNj0c91RRvib5tV77WTZG4DQVgmSwi8osGIDGZnZjpx5nMYU25m9b7NSJW6+kGYzPy/FHwaZtjg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251029-f23e92a5", "", { "os": "win32", "cpu": "x64" }, "sha512-gIpKmue8IbydtxBBfUmC8VtYTkb8t8fs3A2h8CZ/1dJZdfV07ndj5OEPPOVVAqg+4MY+PYwn3cxroJcZ4QbzJQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.31", "", { "os": "win32", "cpu": "x64" }, "sha512-LhyPfR5PuX6hY1LBteAUz5khO8hxV3rLnk2inGEDMffBUkrN2XW0+R635BIIFtq/tYFeTf0mzf+/DwvhiLcgbg=="], - "@opentui/react": ["@opentui/react@0.0.0-20251029-f23e92a5", "", { "dependencies": { "@opentui/core": "0.0.0-20251029-f23e92a5", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-wFA9l6WDjqcB/3N2Rwaxmyplaxks7iS8ZETK0StKN/B8SsyYLZQu8uM9JNGKDovruC0dUmlcwLloA00XaySK1w=="], + "@opentui/react": ["@opentui/react@0.1.31", "", { "dependencies": { "@opentui/core": "0.1.31", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-VG+6PrhuKekHpzMSJlGFV76OiytP55RXMZLz3D4eq19/T6to1GTL97lYgZbsNgxwhl3uB9OY61pr2Jir6/CBkw=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], diff --git a/cli/package.json b/cli/package.json index 02ec04bc5..c55be8ca0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -33,8 +33,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "0.0.0-20251029-f23e92a5", - "@opentui/react": "0.0.0-20251029-f23e92a5", + "@opentui/core": "0.1.31", + "@opentui/react": "0.1.31", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", From ddad28c922547d96e54d399b46d5bb604d561d52 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 30 Oct 2025 17:23:03 -0700 Subject: [PATCH 15/41] fix: clean up agent toggle ui --- cli/src/chat.tsx | 5 +- cli/src/components/branch-item.tsx | 41 +++++++++----- cli/src/components/message-block.tsx | 84 ++++++++++++++++++++-------- cli/src/components/raised-pill.tsx | 2 +- cli/src/components/tool-item.tsx | 12 ++-- cli/src/utils/theme-system.ts | 2 + 6 files changed, 100 insertions(+), 46 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index cb7d387f1..72486c6f3 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -254,12 +254,15 @@ export const App = ({ ? theme.chromeText : theme.agentResponseCount + const logoColor = + resolvedThemeName === 'dark' ? '#4ade80' : '#15803d' + const buildBlocks = (listId: string): ContentBlock[] => { const result: ContentBlock[] = [ { type: 'text', content: '\n\n' + LOGO_BLOCK, - color: theme.agentToggleExpandedBg, + color: logoColor, }, ] diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index 8df8d3c34..6fa895fab 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -90,6 +90,9 @@ export const BranchItem = ({ ? `${statusLabel} ${statusIndicator}` : `${statusIndicator} ${statusLabel}` : null + const showCollapsedPreview = + (isStreaming && !!streamingPreview) || + (!isStreaming && !!finishedPreview) const isTextRenderable = (value: ReactNode): boolean => { if (value === null || value === undefined || typeof value === 'boolean') { @@ -186,8 +189,9 @@ export const BranchItem = ({ flexDirection: 'column', gap: 0, flexShrink: 0, - marginTop: 1, + marginTop: 0, marginBottom: 0, + paddingBottom: 0, width: '100%', }} > @@ -211,8 +215,8 @@ export const BranchItem = ({ style={{ flexDirection: 'column', gap: 0, - paddingLeft: 1, - paddingRight: 1, + paddingLeft: 0, + paddingRight: 0, paddingTop: 0, paddingBottom: 0, width: '100%', @@ -235,7 +239,7 @@ export const BranchItem = ({ paddingLeft: 1, paddingRight: 1, paddingTop: 0, - paddingBottom: 0, + paddingBottom: isCollapsed && !showCollapsedPreview ? 0 : 1, width: '100%', }} onMouseDown={onToggle} @@ -262,13 +266,13 @@ export const BranchItem = ({ {isCollapsed ? ( - (isStreaming && streamingPreview) || (!isStreaming && finishedPreview) ? ( + showCollapsedPreview ? ( - Prompt + + Prompt + )} {renderExpandedContent(content)} - + diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 9b1868984..b09669271 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -112,6 +112,7 @@ export const MessageBlock = ({ indentLevel: number, keyPrefix: string, branchMeta: ToolBranchMeta, + marginTop: number = 0, ): React.ReactNode => { if (toolBlock.toolName === 'end_turn') { return null @@ -186,7 +187,12 @@ export const MessageBlock = ({ registerAgentRef(toolBlock.toolCallId, el)} - style={{ flexDirection: 'column', gap: 0, marginLeft: indentationOffset }} + style={{ + flexDirection: 'column', + gap: 0, + marginLeft: indentationOffset, + marginTop, + }} > , indentLevel: number, keyPrefix: string, + marginTop: number = 0, ): React.ReactNode { const isCollapsed = collapsedAgents.has(agentBlock.agentId) const isStreaming = @@ -217,7 +224,9 @@ export const MessageBlock = ({ const allTextContent = agentBlock.blocks ?.filter((nested) => nested.type === 'text') - .map((nested) => (nested as any).content) + .map((nested) => + trimTrailingNewlines(String((nested as any).content ?? '')), + ) .join('') || '' const lines = allTextContent.split('\n').filter((line) => line.trim()) const firstLine = lines[0] || '' @@ -259,7 +268,12 @@ export const MessageBlock = ({ registerAgentRef(agentBlock.agentId, el)} - style={{ flexDirection: 'column', gap: 0, marginLeft: indentationOffset }} + style={{ + flexDirection: 'column', + gap: 0, + marginLeft: indentationOffset, + marginTop, + }} > , keyPrefix: string, + marginTop: number = 0, ): React.ReactNode { const TRUNCATE_LIMIT = 5 const isCollapsed = collapsedAgents.has(agentListBlock.id) @@ -341,6 +356,7 @@ export const MessageBlock = ({ registerAgentRef(agentListBlock.id, el)} + style={{ marginTop }} > { + const nestedPrevBlock = + nestedIdx > 0 ? nestedBlocks[nestedIdx - 1] : null + const nestedMarginTop = nestedPrevBlock ? 1 : 0 if (nestedBlock.type === 'text') { const nestedStatus = typeof (nestedBlock as any).status === 'string' @@ -376,9 +396,12 @@ export const MessageBlock = ({ : undefined const isNestedStreamingText = parentIsStreaming || nestedStatus === 'running' + const sanitizedNestedContent = trimTrailingNewlines( + String((nestedBlock as any).content ?? ''), + ) const rawNestedContent = isNestedStreamingText - ? trimTrailingNewlines(nestedBlock.content) - : nestedBlock.content.trim() + ? sanitizedNestedContent + : sanitizedNestedContent.trim() const renderKey = `${keyPrefix}-text-${nestedIdx}` const markdownOptionsForLevel = getAgentMarkdownOptions(indentLevel) const renderedContent = hasMarkdown(rawNestedContent) @@ -388,7 +411,8 @@ export const MessageBlock = ({ : rawNestedContent const nestedTextColor = resolveThemeColor(theme.agentText) const nestedTextStyle: Record = { - marginLeft: Math.max(0, indentLevel * 2), + marginLeft: Math.max(0, indentationOffset), + marginTop: nestedMarginTop, } if (nestedTextColor) { nestedTextStyle.fg = nestedTextColor @@ -411,6 +435,7 @@ export const MessageBlock = ({ indentLevel, `${keyPrefix}-tool-${nestedBlock.toolCallId}`, branchMeta, + nestedMarginTop, ), ) } else if (nestedBlock.type === 'agent') { @@ -419,6 +444,7 @@ export const MessageBlock = ({ nestedBlock, indentLevel, `${keyPrefix}-agent-${nestedIdx}`, + nestedMarginTop, ), ) } @@ -452,22 +478,25 @@ export const MessageBlock = ({ {blocks ? ( {blocks.map((block, idx) => { + const prevBlock = idx > 0 ? blocks[idx - 1] : null + const marginTop = prevBlock ? 1 : 0 if (block.type === 'text') { const isStreamingText = isLoading || !isComplete const hasMarkdownContent = hasMarkdown(block.content) + const sanitizedContent = trimTrailingNewlines( + String(block.content ?? ''), + ) const rawContent = isStreamingText - ? trimTrailingNewlines(block.content) + ? sanitizedContent : hasMarkdownContent - ? block.content - : block.content.trim() + ? sanitizedContent + : sanitizedContent.trim() const renderKey = `${messageId}-text-${idx}` const renderedContent = hasMarkdownContent ? isStreamingText ? renderStreamingMarkdown(rawContent, markdownOptions) : renderMarkdown(rawContent, markdownOptions) : rawContent - const prevBlock = idx > 0 ? blocks[idx - 1] : null - const marginTop = prevBlock && prevBlock.type === 'text' ? 1 : 0 const blockTextColor = resolveThemeColor(block.color, textColor) const blockStyle: Record = { marginTop } if (blockTextColor) { @@ -482,25 +511,31 @@ export const MessageBlock = ({ {renderedContent} ) - } else if (block.type === 'tool') { - const branchMeta = - topLevelToolMeta?.get(idx) ?? defaultToolBranchMeta - return renderToolBranch( - block, - 0, - `${messageId}-tool-${block.toolCallId}`, - branchMeta, - ) - } else if (block.type === 'agent') { + } + if (block.type === 'tool') { + const branchMeta = + topLevelToolMeta?.get(idx) ?? defaultToolBranchMeta + return renderToolBranch( + block, + 0, + `${messageId}-tool-${block.toolCallId}`, + branchMeta, + marginTop, + ) + } + if (block.type === 'agent') { return renderAgentBranch( block, 0, `${messageId}-agent-${block.agentId}`, + marginTop, ) - } else if (block.type === 'agent-list') { + } + if (block.type === 'agent-list') { return renderAgentListBranch( block, `${messageId}-agent-list-${block.id}`, + marginTop, ) } return null @@ -509,9 +544,10 @@ export const MessageBlock = ({ ) : ( (() => { const isStreamingMessage = isLoading || !isComplete + const sanitizedContent = trimTrailingNewlines(content) const normalizedContent = isStreamingMessage - ? trimTrailingNewlines(content) - : content.trim() + ? sanitizedContent + : sanitizedContent.trim() const displayContent = hasMarkdown(normalizedContent) ? isStreamingMessage ? renderStreamingMarkdown(normalizedContent, markdownOptions) diff --git a/cli/src/components/raised-pill.tsx b/cli/src/components/raised-pill.tsx index 92004e783..cbbed6d08 100644 --- a/cli/src/components/raised-pill.tsx +++ b/cli/src/components/raised-pill.tsx @@ -27,7 +27,7 @@ export const RaisedPill = ({ frameColor, textColor, fillColor, - padding = 1, + padding = 2, onPress, style, }: RaisedPillProps): React.ReactNode => { diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index f8369e827..88e662121 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -114,8 +114,8 @@ export const ToolItem = ({ > agentToggleHeaderText: '#0f172a', agentToggleText: '#0f172a', chromeText: '#0f172a', + inputFg: '#000000', + inputFocusedFg: '#000000', inputPlaceholder: '#64748b', markdown: { inlineCodeFg: '#0f62fe', From 206d7bcf7cbcb31613310e42049090084cd71d81 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 30 Oct 2025 22:26:57 -0700 Subject: [PATCH 16/41] chore: tidy footer layout and branch spacing --- cli/src/chat.tsx | 4 ++-- cli/src/components/message-block.tsx | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 72486c6f3..2ef0df425 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1103,11 +1103,11 @@ export const App = ({ - + { const nestedPrevBlock = nestedIdx > 0 ? nestedBlocks[nestedIdx - 1] : null - const nestedMarginTop = nestedPrevBlock ? 1 : 0 + const nestedMarginTop = + nestedPrevBlock && + (nestedPrevBlock.type === 'text' || nestedBlock.type === 'text') + ? 1 + : 0 if (nestedBlock.type === 'text') { const nestedStatus = typeof (nestedBlock as any).status === 'string' @@ -479,7 +483,11 @@ export const MessageBlock = ({ {blocks.map((block, idx) => { const prevBlock = idx > 0 ? blocks[idx - 1] : null - const marginTop = prevBlock ? 1 : 0 + const marginTop = + prevBlock && + (prevBlock.type === 'text' || block.type === 'text') + ? 1 + : 0 if (block.type === 'text') { const isStreamingText = isLoading || !isComplete const hasMarkdownContent = hasMarkdown(block.content) From 215d6179f67d05dec26595555fd9676bbe1cfc36 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 30 Oct 2025 22:29:09 -0700 Subject: [PATCH 17/41] chore: align input text color with message theme --- cli/src/utils/theme-system.ts | 50 +++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index d5e839b1e..d6d97f4b5 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -180,29 +180,39 @@ const applyNeutralTextDefaults = ( return allowDefault ? 'default' : fallback } + const resolvedMessageAiText = resolveColor( + theme.messageAiText, + neutrals.primary, + allowTerminalDefaults, + ) + const resolvedMessageUserText = resolveColor( + theme.messageUserText, + neutrals.primary, + allowTerminalDefaults, + ) + const messageUserFallback = + resolvedMessageUserText === 'default' + ? neutrals.primary + : resolvedMessageUserText + + const resolvedInputFg = resolveColor( + theme.inputFg, + messageUserFallback, + allowTerminalDefaults, + ) + const resolvedInputFocusedFg = resolveColor( + theme.inputFocusedFg ?? theme.inputFg ?? messageUserFallback, + messageUserFallback, + allowTerminalDefaults, + ) + const adjustedTheme: ChatTheme = { ...theme, chromeText: theme.chromeText ?? neutrals.primary, - messageAiText: resolveColor( - theme.messageAiText, - neutrals.primary, - allowTerminalDefaults, - ), - messageUserText: resolveColor( - theme.messageUserText, - neutrals.primary, - allowTerminalDefaults, - ), - inputFg: resolveColor( - theme.inputFg, - neutrals.primary, - allowTerminalDefaults, - ), - inputFocusedFg: resolveColor( - theme.inputFocusedFg ?? theme.inputFg, - neutrals.primary, - allowTerminalDefaults, - ), + messageAiText: resolvedMessageAiText, + messageUserText: resolvedMessageUserText, + inputFg: resolvedInputFg, + inputFocusedFg: resolvedInputFocusedFg, agentText: resolveColor( theme.agentText, neutrals.primary, From 0881550e6cdfd5629917d998ad94cf5db6e8de01 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 30 Oct 2025 22:29:59 -0700 Subject: [PATCH 18/41] style: use heavier cursor glyph in multiline input --- cli/src/components/multiline-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index e8fec6777..e167b3ba5 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -64,7 +64,7 @@ function findNextWordBoundary(text: string, cursor: number): number { return pos } -const CURSOR_CHAR = '▏' +const CURSOR_CHAR = '┃' interface MultilineInputProps { value: string From 951ed713a9659bcfa55e3a5001e2f09e12f379f4 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 30 Oct 2025 22:37:06 -0700 Subject: [PATCH 19/41] style: apply message attributes to input text --- cli/src/chat.tsx | 1 + cli/src/components/multiline-input.tsx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 2ef0df425..707f8ad2e 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1118,6 +1118,7 @@ export const App = ({ theme={theme} width={inputWidth} onKeyIntercept={handleSuggestionMenuKey} + textAttributes={theme.messageTextAttributes} ref={inputRef} /> diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index e167b3ba5..8fe0a7cbb 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -93,6 +93,7 @@ interface MultilineInputProps { | 'statusAccent' > width: number + textAttributes?: number } export type MultilineInputHandle = { @@ -112,6 +113,7 @@ export const MultilineInput = forwardRef< maxHeight = 5, theme, width, + textAttributes, onKeyIntercept, }: MultilineInputProps, forwardedRef, @@ -605,6 +607,8 @@ export const MultilineInput = forwardRef< } if (isPlaceholder) { textStyle.attributes = TextAttributes.DIM + } else if (textAttributes !== undefined && textAttributes !== 0) { + textStyle.attributes = textAttributes } const cursorFg = resolveFg(theme.cursor, theme.statusAccent) From cb2c7321b34cc4449dd59b6308cd06de0b6d113b Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 31 Oct 2025 16:20:03 -0700 Subject: [PATCH 20/41] fix: minor leftover UI issues --- cli/knowledge.md | 31 +++++++- cli/src/components/branch-item.tsx | 20 ++--- cli/src/components/message-block.tsx | 112 ++++++++++++++++++++++++--- cli/src/components/tool-item.tsx | 9 +-- cli/src/components/tool-renderer.tsx | 57 ++++++++++---- 5 files changed, 183 insertions(+), 46 deletions(-) diff --git a/cli/knowledge.md b/cli/knowledge.md index f856d13d6..a901b8fe7 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -507,7 +507,36 @@ The bug occurred when tool toggles were rendered. Agent toggles worked fine, but ## Toggle Branch Rendering -Agent and tool toggles in the TUI render inside `` components. Expanded content must resolve to plain strings or StyledText-compatible fragments (``, ``, ``). +Agent and tool toggles in the TUI render inside `` components. Expanded content must resolve to plain strings or StyledText-compatible fragments (``, ``, ``). Any React tree we pass into a toggle must either already be a `` node or be wrapped in one so that downstream child elements never escape a text container. If we hand off plain markdown React fragments directly to ``, OpenTUI will crash because the fragments often expand to bare `` elements. + +Example: +Tool markdown output (via `renderMarkdown`) now gets wrapped in a `` element before reaching `BranchItem`. Without this wrapper, the renderer emits `` nodes that hit `` and cause `Component of type "span" must be created inside of a text node`. Wrapping the markdown and then composing it with any extra metadata keeps OpenTUI happy. + + ```tsx + const displayContent = renderContentWithMarkdown(fullContent, false, options) + + const renderableDisplayContent = + displayContent + ? ( + + {displayContent} + + ) + : null + + const combinedContent = toolRenderConfig.content ? ( + + + {toolRenderConfig.content} + + {renderableDisplayContent} + + ) : renderableDisplayContent + ``` ### TextNodeRenderable Constraint diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index 9782f0042..ea48d1d93 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -33,6 +33,7 @@ interface BranchItemProps { statusIndicator?: string theme: ChatTheme onToggle: () => void + showBorder?: boolean } export const BranchItem = ({ @@ -50,6 +51,7 @@ export const BranchItem = ({ statusIndicator = '●', theme, onToggle, + showBorder = true, }: BranchItemProps) => { const resolveFg = ( color?: string | null, @@ -199,10 +201,10 @@ export const BranchItem = ({ }} > - `${' '.repeat(indentLevel)}${isLastBranch ? '└─ ' : '├─ '}` + const computeBranchChar = ( + ancestorBranchStates: boolean[], + isLastBranch: boolean, + ) => { + const ancestorPrefix = ancestorBranchStates + .map((ancestorIsLast) => (ancestorIsLast ? ' ' : '│ ')) + .join('') + return `${ancestorPrefix}${isLastBranch ? '└─ ' : '├─ '}` + } const renderContentWithMarkdown = ( rawContent: string, @@ -145,6 +154,7 @@ export const MessageBlock = ({ indentLevel: number, isLastBranch: boolean, keyPrefix: string, + ancestorBranchStates: boolean[], ): React.ReactNode => { if (toolBlock.toolName === 'end_turn') { return null @@ -172,15 +182,48 @@ export const MessageBlock = ({ ? `$ ${(toolBlock.input as any).command.trim()}` : null - const streamingPreview = isStreaming + const branchChar = computeBranchChar(ancestorBranchStates, isLastBranch) + const indentPrefix = branchChar.replace(/[├└]─\s*$/, '') + const previewBasePrefix = + indentPrefix.length > 0 ? `${indentPrefix}│ ` : ' │ ' + const toggleLabel = `${branchChar ? `${branchChar} ` : ''}${isCollapsed ? '▸' : '▾'} ` + const branchIndentWidth = stringWidth(branchChar) + const headerPrefixWidth = stringWidth(toggleLabel) + const previewBaseWidth = stringWidth(previewBasePrefix) + const alignmentPadding = Math.max(0, headerPrefixWidth - previewBaseWidth) + const paddedPreviewPrefix = `${previewBasePrefix}${' '.repeat(alignmentPadding)}` + const blankPreviewPrefix = + previewBasePrefix.replace(/\s+$/, '') || previewBasePrefix + const toolRenderConfig = getToolRenderConfig(toolBlock, theme, { + availableWidth, + indentationOffset: branchIndentWidth, + previewPrefix: previewBasePrefix, + labelWidth: headerPrefixWidth, + }) + const formatPreview = (value: string | null): string => { + if (!value) return '' + const rawLines = value.split('\n') + const decorated = rawLines.map((line) => + line.trim().length > 0 + ? `${paddedPreviewPrefix}${line}` + : blankPreviewPrefix, + ) + if (!decorated.some((line) => line.trim().length === 0)) { + decorated.push(blankPreviewPrefix) + } + return decorated.join('\n') + } + const rawStreamingPreview = isStreaming ? commandPreview ?? `${sanitizePreview(firstLine)}...` : '' - + const streamingPreview = isStreaming + ? formatPreview(rawStreamingPreview) + : '' + const collapsedPreviewBase = + toolRenderConfig.collapsedPreview ?? + getToolFinishedPreview(toolBlock, commandPreview, lastLine) const finishedPreview = - !isStreaming && isCollapsed - ? getToolFinishedPreview(toolBlock, commandPreview, lastLine) - : '' - + !isStreaming && isCollapsed ? formatPreview(collapsedPreviewBase) : '' const agentMarkdownOptions = getAgentMarkdownOptions(indentLevel) const displayContent = renderContentWithMarkdown( fullContent, @@ -188,7 +231,43 @@ export const MessageBlock = ({ agentMarkdownOptions, ) - const branchChar = computeBranchChar(indentLevel, isLastBranch) + const renderableDisplayContent = + displayContent === null || + displayContent === undefined || + displayContent === false || + displayContent === '' + ? null + : ( + + {displayContent} + + ) + + const combinedContent = toolRenderConfig.content ? ( + + + {toolRenderConfig.content} + + {renderableDisplayContent} + + ) : renderableDisplayContent + + const headerName = toolRenderConfig.path + ? `${displayInfo.name} • ${toolRenderConfig.path}` + : displayInfo.name return ( registerAgentRef(toolBlock.toolCallId, el)} > onToggleCollapsed(toolBlock.toolCallId)} + showBorder={false} /> ) @@ -216,6 +296,7 @@ export const MessageBlock = ({ indentLevel: number, isLastBranch: boolean, keyPrefix: string, + ancestorBranchStates: boolean[], ): React.ReactNode { const isCollapsed = collapsedAgents.has(agentBlock.agentId) const isStreaming = @@ -241,12 +322,14 @@ export const MessageBlock = ({ ? sanitizePreview(agentBlock.initialPrompt) : '' - const branchChar = computeBranchChar(indentLevel, isLastBranch) + const branchChar = '' + const nextAncestorBranches = [...ancestorBranchStates, isLastBranch] const childNodes = renderAgentBody( agentBlock, indentLevel + 1, keyPrefix, isStreaming, + nextAncestorBranches, ) const displayContent = @@ -378,6 +461,7 @@ export const MessageBlock = ({ indentLevel: number, keyPrefix: string, parentIsStreaming: boolean, + ancestorBranchStates: boolean[], ): React.ReactNode[] { const nestedBlocks = agentBlock.blocks ?? [] const nodes: React.ReactNode[] = [] @@ -458,6 +542,7 @@ export const MessageBlock = ({ indentLevel, isLastBranch, `${keyPrefix}-tool-${nestedBlock.toolCallId}`, + ancestorBranchStates, ), ) break @@ -471,6 +556,7 @@ export const MessageBlock = ({ indentLevel, isLastBranch, `${keyPrefix}-agent-${nestedIdx}`, + ancestorBranchStates, ), ) break @@ -561,6 +647,7 @@ export const MessageBlock = ({ 0, isLastBranch, `${messageId}-tool-${block.toolCallId}`, + [], ) } @@ -571,6 +658,7 @@ export const MessageBlock = ({ 0, isLastBranch, `${messageId}-agent-${block.agentId}`, + [], ) } diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index 88e662121..ad232fea2 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -198,17 +198,12 @@ export const ToolItem = ({ > - {connectorSymbol}{' '} + {`${connectorSymbol} `} {name} - {hasTitleAccessory ? ( - <> - {' '} - {titleAccessory} - - ) : null} + {hasTitleAccessory && titleAccessory ? titleAccessory : null} {isCollapsed ? renderConnectedSection(previewNode) : null} diff --git a/cli/src/components/tool-renderer.tsx b/cli/src/components/tool-renderer.tsx index 440a474c9..0be0bd050 100644 --- a/cli/src/components/tool-renderer.tsx +++ b/cli/src/components/tool-renderer.tsx @@ -8,7 +8,7 @@ import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' type ToolBlock = Extract export type ToolRenderConfig = { - titleAccessory?: React.ReactNode + path?: string content?: React.ReactNode collapsedPreview?: string } @@ -16,6 +16,8 @@ export type ToolRenderConfig = { export type ToolRenderOptions = { availableWidth: number indentationOffset: number + previewPrefix?: string + labelWidth: number } const isRecord = (value: unknown): value is Record => { @@ -45,9 +47,16 @@ const summarizeFiles = ( maxItems: number, options: ToolRenderOptions, ): string | null => { + const previewPrefix = options.previewPrefix ?? '' + const previewPrefixWidth = stringWidth(previewPrefix) + const alignmentPadding = Math.max( + 0, + options.labelWidth - previewPrefixWidth, + ) + const totalPrefixWidth = previewPrefixWidth + alignmentPadding const maxWidth = Math.max( 20, - options.availableWidth - options.indentationOffset - 6, + options.availableWidth - options.indentationOffset - totalPrefixWidth - 6, ) if (!Array.isArray(entries) || entries.length === 0) { @@ -126,34 +135,48 @@ const getListDirectoryRender = ( const summaryColor = resolveThemeColor(theme.agentContentText) ?? theme.statusSecondary - const pathColor = theme.statusAccent const baseAttributes = theme.messageTextAttributes ?? 0 const getAttributes = (extra: number = 0): number | undefined => { const combined = baseAttributes | extra return combined === 0 ? undefined : combined } + const previewPrefix = options.previewPrefix ?? '' + const previewPrefixWidth = stringWidth(previewPrefix) + const alignmentPadding = Math.max( + 0, + options.labelWidth - previewPrefixWidth, + ) + const alignmentSpaces = ' '.repeat(alignmentPadding) + const paddedPrefix = `${previewPrefix}${alignmentSpaces}` + const blankPrefix = + previewPrefix.replace(/\s+$/, '') || previewPrefix const content = summaryLine !== null ? ( - - {summaryLine} - + + + {`${paddedPrefix}${summaryLine}`} + + {previewPrefix ? ( + + {blankPrefix} + + ) : null} + ) : null const collapsedPreview = summaryLine ?? undefined - const titleAccessory = path ? ( - - {path} - - ) : undefined - return { - titleAccessory, + path: path ?? undefined, content, collapsedPreview, } From 0314cb881866324607b06a9c9d131ce17e4a88bf Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 31 Oct 2025 17:52:40 -0700 Subject: [PATCH 21/41] Keep tool call branches collapsed --- cli/src/components/branch-item.tsx | 55 +++++++++++++++++----------- cli/src/components/message-block.tsx | 18 ++++----- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/branch-item.tsx index ea48d1d93..cd0103963 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/branch-item.tsx @@ -32,8 +32,10 @@ interface BranchItemProps { statusColor?: string statusIndicator?: string theme: ChatTheme - onToggle: () => void + onToggle?: () => void showBorder?: boolean + toggleEnabled?: boolean + titleSuffix?: string } export const BranchItem = ({ @@ -52,6 +54,8 @@ export const BranchItem = ({ theme, onToggle, showBorder = true, + toggleEnabled = true, + titleSuffix, }: BranchItemProps) => { const resolveFg = ( color?: string | null, @@ -80,9 +84,8 @@ export const BranchItem = ({ ? theme.statusAccent : theme.chromeText ?? toggleFrameColor const toggleLabelColor = theme.chromeText ?? toggleFrameColor - const toggleLabel = `${ - branchChar ? `${branchChar} ` : '' - }${isCollapsed ? '▸' : '▾'} ` + const toggleIndicator = toggleEnabled ? (isCollapsed ? '▸ ' : '▾ ') : '' + const toggleLabel = `${branchChar}${toggleIndicator}` const collapseButtonFrame = theme.agentToggleExpandedBg const collapseButtonText = collapseButtonFrame const toggleFrameFg = resolveFg(toggleFrameColor, fallbackTextColor) @@ -247,7 +250,7 @@ export const BranchItem = ({ paddingBottom: isCollapsed ? 0 : 1, width: '100%', }} - onMouseDown={onToggle} + onMouseDown={toggleEnabled && onToggle ? onToggle : undefined} > @@ -259,6 +262,14 @@ export const BranchItem = ({ > {name} + {titleSuffix ? ( + + {` ${titleSuffix}`} + + ) : null} {statusText ? ( )} {renderExpandedContent(content)} - - - + {toggleEnabled && onToggle && ( + + + + )} )} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index c88b6f4eb..0e32a7d92 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -86,7 +86,7 @@ export const MessageBlock = ({ const ancestorPrefix = ancestorBranchStates .map((ancestorIsLast) => (ancestorIsLast ? ' ' : '│ ')) .join('') - return `${ancestorPrefix}${isLastBranch ? '└─ ' : '├─ '}` + return `${ancestorPrefix}${isLastBranch ? '└ ' : '├ '}` } const renderContentWithMarkdown = ( @@ -161,7 +161,7 @@ export const MessageBlock = ({ } const displayInfo = getToolDisplayInfo(toolBlock.toolName) - const isCollapsed = collapsedAgents.has(toolBlock.toolCallId) + const isCollapsed = true const isStreaming = streamingAgents.has(toolBlock.toolCallId) const inputContent = `\`\`\`json\n${JSON.stringify(toolBlock.input, null, 2)}\n\`\`\`` @@ -183,12 +183,11 @@ export const MessageBlock = ({ : null const branchChar = computeBranchChar(ancestorBranchStates, isLastBranch) - const indentPrefix = branchChar.replace(/[├└]─\s*$/, '') + const indentPrefix = branchChar.replace(/[├└]\s*$/, '') const previewBasePrefix = indentPrefix.length > 0 ? `${indentPrefix}│ ` : ' │ ' - const toggleLabel = `${branchChar ? `${branchChar} ` : ''}${isCollapsed ? '▸' : '▾'} ` const branchIndentWidth = stringWidth(branchChar) - const headerPrefixWidth = stringWidth(toggleLabel) + const headerPrefixWidth = stringWidth(branchChar) const previewBaseWidth = stringWidth(previewBasePrefix) const alignmentPadding = Math.max(0, headerPrefixWidth - previewBaseWidth) const paddedPreviewPrefix = `${previewBasePrefix}${' '.repeat(alignmentPadding)}` @@ -223,7 +222,7 @@ export const MessageBlock = ({ toolRenderConfig.collapsedPreview ?? getToolFinishedPreview(toolBlock, commandPreview, lastLine) const finishedPreview = - !isStreaming && isCollapsed ? formatPreview(collapsedPreviewBase) : '' + !isStreaming ? formatPreview(collapsedPreviewBase) : '' const agentMarkdownOptions = getAgentMarkdownOptions(indentLevel) const displayContent = renderContentWithMarkdown( fullContent, @@ -265,9 +264,7 @@ export const MessageBlock = ({ ) : renderableDisplayContent - const headerName = toolRenderConfig.path - ? `${displayInfo.name} • ${toolRenderConfig.path}` - : displayInfo.name + const headerName = displayInfo.name return ( onToggleCollapsed(toolBlock.toolCallId)} showBorder={false} + toggleEnabled={false} + titleSuffix={toolRenderConfig.path} /> ) From fb4483bf7417b7935d23e4e6d3b7e05938c9ff70 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 31 Oct 2025 18:17:27 -0700 Subject: [PATCH 22/41] Restore agents CLI handler from origin/main --- npm-app/src/cli-handlers/agents.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/npm-app/src/cli-handlers/agents.ts b/npm-app/src/cli-handlers/agents.ts index b1a2eedc8..5064c619d 100644 --- a/npm-app/src/cli-handlers/agents.ts +++ b/npm-app/src/cli-handlers/agents.ts @@ -304,15 +304,17 @@ function buildAllContentLines() { const cleanDescription = agent.description ? agent.description.replace(/\u001b\[[0-9;]*m/g, '') : '' + const availableWidth = terminalWidth - 4 // Account for padding if (isSelected) { const headerWidth = Math.min(terminalWidth - 6, 60) lines.push(` ${cyan('┌' + '─'.repeat(headerWidth + 2) + '┐')}`) - // Title row inside the header box - const namePadding = Math.max(0, headerWidth - cleanName.length) + // Right-aligned title with separator line + const titlePadding = Math.max(0, headerWidth - cleanName.length - 4) + const separatorLine = '─'.repeat(titlePadding) lines.push( - ` ${cyan('│')} ${agent.name}${' '.repeat(namePadding)} ${cyan('│')}`, + ` ${cyan('│')} ${gray(separatorLine)} ${agent.name} ${cyan('│')}`, ) if (agent.description) { @@ -326,8 +328,13 @@ function buildAllContentLines() { } lines.push(` ${cyan('└' + '─'.repeat(headerWidth + 2) + '┘')}`) } else { - // Title line when header is not selected - lines.push(` ${agent.name}`) + // Right-aligned title with separator line for unselected + const titlePadding = Math.max( + 0, + availableWidth - cleanName.length - 4, + ) + const separatorLine = gray('─'.repeat(titlePadding)) + lines.push(` ${separatorLine} ${agent.name}`) if (agent.description) { lines.push(` ${agent.description}`) From 145ca63ccf5033e98d1e2c722d6dc0534c3bddd8 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 31 Oct 2025 18:17:53 -0700 Subject: [PATCH 23/41] fix: remove temp md files --- bun_dev_auto_output.md | 434 ----------------------------------------- merge-notes.md | 29 --- 2 files changed, 463 deletions(-) delete mode 100644 bun_dev_auto_output.md delete mode 100644 merge-notes.md diff --git a/bun_dev_auto_output.md b/bun_dev_auto_output.md deleted file mode 100644 index 5ea8e413f..000000000 --- a/bun_dev_auto_output.md +++ /dev/null @@ -1,434 +0,0 @@ -## Snapshot 2025-10-31 11:19:38 PDT - -``` - Codebuff can read and write files in this repository, and run terminal - commands to help you build. - - ╭────────────────────────────────────────────────────────────────────────────╮ - │ ▸ 77 local agents │ - │ │ - │ • Ask Buffy (ask) │ - │ • Base Lite Grok 4 Fast (base-lite-grok-4-fast) │ - │ • Bob the Agent Builder (agent-builder) │ - │ • Buffy the Fast No Validation Orchestrator (base2-fast-no-validation) │ - │ • Buffy the Fast Orchestrator (base2-fast) │ - │ ... 72 more agents available │ - │ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - │ [11:19 AM] - │ spawn a file explorer - - thinking... - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:39 PDT - -``` - Codebuff can read and write files in this repository, and run terminal - commands to help you build. - - ╭────────────────────────────────────────────────────────────────────────────╮ - │ ▸ 77 local agents │ - │ │ - │ • Ask Buffy (ask) │ - │ • Base Lite Grok 4 Fast (base-lite-grok-4-fast) │ - │ • Bob the Agent Builder (agent-builder) │ - │ • Buffy the Fast No Validation Orchestrator (base2-fast-no-validation) │ - │ • Buffy the Fast Orchestrator (base2-fast) │ - │ ... 72 more agents available │ - │ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - │ [11:19 AM] - │ spawn a file explorer - - I'll help you explore the file structure of this project. - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:40 PDT - -``` - commands to help you build. - - ╭────────────────────────────────────────────────────────────────────────────╮ - │ ▸ 77 local agents │ - │ │ - │ • Ask Buffy (ask) │ - │ • Base Lite Grok 4 Fast (base-lite-grok-4-fast) │ - │ • Bob the Agent Builder (agent-builder) │ - │ • Buffy the Fast No Validation Orchestrator (base2-fast-no-validation) │ - │ • Buffy the Fast Orchestrator (base2-fast) │ - │ ... 72 more agents available │ - │ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - │ [11:19 AM] - │ spawn a file explorer - - I'll help you explore the file structure of this project. Let me spawn a - directory-lister agent to show you the contents of the project directories - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:41 PDT - -``` - commands to help you build. - - ╭────────────────────────────────────────────────────────────────────────────╮ - │ ▸ 77 local agents │ - │ │ - │ • Ask Buffy (ask) │ - │ • Base Lite Grok 4 Fast (base-lite-grok-4-fast) │ - │ • Bob the Agent Builder (agent-builder) │ - │ • Buffy the Fast No Validation Orchestrator (base2-fast-no-validation) │ - │ • Buffy the Fast Orchestrator (base2-fast) │ - │ ... 72 more agents available │ - │ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - │ [11:19 AM] - │ spawn a file explorer - - I'll help you explore the file structure of this project. Let me spawn a - directory-lister agent to show you the contents of the project directories - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:42 PDT - -``` - commands to help you build. - - ╭────────────────────────────────────────────────────────────────────────────╮ - │ ▸ 77 local agents │ - │ │ - │ • Ask Buffy (ask) │ - │ • Base Lite Grok 4 Fast (base-lite-grok-4-fast) │ - │ • Bob the Agent Builder (agent-builder) │ - │ • Buffy the Fast No Validation Orchestrator (base2-fast-no-validation) │ - │ • Buffy the Fast Orchestrator (base2-fast) │ - │ ... 72 more agents available │ - │ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - │ [11:19 AM] - │ spawn a file explorer - - I'll help you explore the file structure of this project. Let me spawn a - directory-lister agent to show you the contents of the project directories - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:43 PDT - -``` - │ │ code-map, internal, bigquery, ... │ - │ │ │ - │ ├ List Directories python-app │ - │ │ LICENSE, knowledge.md, pyproject.toml, ... │ - │ │ │ - │ ├ List Directories scripts │ - │ │ generate-tool-definitions.ts, convert-escaped-newlines.ts, ... │ - │ │ │ - │ ├ List Directories sdk │ - │ │ .npmignore, smoke-test-dist.ts, PUBLISHING.md, ... │ - │ │ │ - │ ├ List Directories web │ - │ │ contentlayer.config.ts, jest.setup.js, knowledge.md, ... │ - │ │ │ - │ └ Set Output │ - │ ╭────────────╮ │ - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:44 PDT - -``` - │ │ code-map, internal, bigquery, ... │ - │ │ │ - │ ├ List Directories python-app │ - │ │ LICENSE, knowledge.md, pyproject.toml, ... │ - │ │ │ - │ ├ List Directories scripts │ - │ │ generate-tool-definitions.ts, convert-escaped-newlines.ts, ... │ - │ │ │ - │ ├ List Directories sdk │ - │ │ .npmignore, smoke-test-dist.ts, PUBLISHING.md, ... │ - │ │ │ - │ ├ List Directories web │ - │ │ contentlayer.config.ts, jest.setup.js, knowledge.md, ... │ - │ │ │ - │ └ Set Output │ - │ ╭────────────╮ │ - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:45 PDT - -``` - │ │ code-map, internal, bigquery, ... │ - │ │ │ - │ ├ List Directories python-app │ - │ │ LICENSE, knowledge.md, pyproject.toml, ... │ - │ │ │ - │ ├ List Directories scripts │ - │ │ generate-tool-definitions.ts, convert-escaped-newlines.ts, ... │ - │ │ │ - │ ├ List Directories sdk │ - │ │ .npmignore, smoke-test-dist.ts, PUBLISHING.md, ... │ - │ │ │ - │ ├ List Directories web │ - │ │ contentlayer.config.ts, jest.setup.js, knowledge.md, ... │ - │ │ │ - │ └ Set Output │ - │ ╭────────────╮ │ - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:46 PDT - -``` - │ ├ List Directories python-app │ - │ │ LICENSE, knowledge.md, pyproject.toml, ... │ - │ │ │ - │ ├ List Directories scripts │ - │ │ generate-tool-definitions.ts, convert-escaped-newlines.ts, ... │ - │ │ │ - │ ├ List Directories sdk │ - │ │ .npmignore, smoke-test-dist.ts, PUBLISHING.md, ... │ - │ │ │ - │ ├ List Directories web │ - │ │ contentlayer.config.ts, jest.setup.js, knowledge.md, ... │ - │ │ │ - │ └ Set Output │ - │ ╭────────────╮ │ - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - I've spawned a explorer that shows the directory structure of your - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:47 PDT - -``` - │ ├ List Directories scripts │ - │ │ generate-tool-definitions.ts, convert-escaped-newlines.ts, ... │ - │ │ │ - │ ├ List Directories sdk │ - │ │ .npmignore, smoke-test-dist.ts, PUBLISHING.md, ... │ - │ │ │ - │ ├ List Directories web │ - │ │ contentlayer.config.ts, jest.setup.js, knowledge.md, ... │ - │ │ │ - │ └ Set Output │ - │ ╭────────────╮ │ - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - I've spawned a explorer that shows the directory structure of your Codebuff - project. Here's what's in your workspace: - Root level: - - `.agents/` - Agent definitions and templatesbackend/` - Backend server - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:48 PDT - -``` - │ │ │ - │ ├ List Directories web │ - │ │ contentlayer.config.ts, jest.setup.js, knowledge.md, ... │ - │ │ │ - │ └ Set Output │ - │ ╭────────────╮ │ - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - I've spawned a explorer that shows the directory structure of your Codebuff - project. Here's what's in your workspace: - Root level: - - `.agents/` - Agent definitions and templatesbackend/` - Backend server code - - `cli/` - CLI application - - `common Shared coded utilities - - `evals/` - Evaluation and testing tools - - `npm-app/` - NPM application package - - `packages - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:49 PDT - -``` - │ │ │ - │ └ Set Output │ - │ ╭────────────╮ │ - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - I've spawned a explorer that shows the directory structure of your Codebuff - project. Here's what's in your workspace: - Root level: - - `.agents/` - Agent definitions and templatesbackend/` - Backend server code - - `cli/` - CLI application - - `common Shared coded utilities - - `evals/` - Evaluation and testing tools - - `npm-app/` - NPM application package - - `packages Monorepo packages (-runtime, bigquery, billing, etc.) - - `python-app/` - Python package - - `scripts/` - Build and utility scripts - - `sdk/` - SDK for - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:50 PDT - -``` - │ │ Collapse │ │ - │ ╰────────────╯ │ - ╰────────────────────────────────────────────────────────────────────────────╯ - - I've spawned a explorer that shows the directory structure of your Codebuff - project. Here's what's in your workspace: - Root level: - - `.agents/` - Agent definitions and templatesbackend/` - Backend server code - - `cli/` - CLI application - - `common Shared coded utilities - - `evals/` - Evaluation and testing tools - - `npm-app/` - NPM application package - - `packages Monorepo packages (-runtime, bigquery, billing, etc.) - - `python-app/` - Python package - - `scripts/` - Build and utility scripts - - `sdk/` - SDK for Codebuff integration - - `web/` - Web frontend (Next.js) - The also revealed subdirectories withinagents/`including specialized agent - types like`file-explorer`, `base - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:51 PDT - -``` - ╰────────────────────────────────────────────────────────────────────────────╯ - - I've spawned a explorer that shows the directory structure of your Codebuff - project. Here's what's in your workspace: - Root level: - - `.agents/` - Agent definitions and templatesbackend/` - Backend server code - - `cli/` - CLI application - - `common Shared coded utilities - - `evals/` - Evaluation and testing tools - - `npm-app/` - NPM application package - - `packages Monorepo packages (-runtime, bigquery, billing, etc.) - - `python-app/` - Python package - - `scripts/` - Build and utility scripts - - `sdk/` - SDK for Codebuff integration - - `web/` - Web frontend (Next.js) - The also revealed subdirectories withinagents/`including specialized agent - types like`file-explorer`, `baseditor/`, `orchestrator/`, `researcher/`, and - more - 17.2s • 13 credits - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` - -## Snapshot 2025-10-31 11:19:52 PDT - -``` - ╰────────────────────────────────────────────────────────────────────────────╯ - - I've spawned a explorer that shows the directory structure of your Codebuff - project. Here's what's in your workspace: - Root level: - - `.agents/` - Agent definitions and templatesbackend/` - Backend server code - - `cli/` - CLI application - - `common Shared coded utilities - - `evals/` - Evaluation and testing tools - - `npm-app/` - NPM application package - - `packages Monorepo packages (-runtime, bigquery, billing, etc.) - - `python-app/` - Python package - - `scripts/` - Build and utility scripts - - `sdk/` - SDK for Codebuff integration - - `web/` - Web frontend (Next.js) - The also revealed subdirectories withinagents/`including specialized agent - types like`file-explorer`, `baseditor/`, `orchestrator/`, `researcher/`, and - more - 17.2s • 13 credits - ────────────────────────────────────────────────────────────────────────────── - ╭────────╮ - ┃Share your thoughts and press Enter… │ FAST │ - ╰────────╯ - ────────────────────────────────────────────────────────────────────────────── -``` diff --git a/merge-notes.md b/merge-notes.md deleted file mode 100644 index 4a40ed4e1..000000000 --- a/merge-notes.md +++ /dev/null @@ -1,29 +0,0 @@ -# Merge Notes: origin/main into brandon/upgrade-opentui-2025-10-28 - -## Theme System -- Kept the upgraded dynamic theme detection from our branch to preserve neutral/transparent defaults and macOS Terminal fallbacks. -- Added static `chatThemes` export to satisfy new tests and upstream imports. -- Normalized the `ThemeColor` type to always emit string values (using `'default'` sentinel) so downstream components can rely on concrete color strings while still resolving terminal defaults via `resolveThemeColor`. - -## Chat Surface (`cli/src/chat.tsx`) -- Merged upstream validation/error handling, dynamic logo sizing, and repo path link while retaining our theme cloning + subscription model. -- Swapped text rendering to use OpenTUI `wrapMode` styles instead of the untyped `wrap` prop to appease the newer JSX typings. -- Applied `resolveThemeColor` wherever theme-derived colors reach JSX to avoid passing `'default'` through to components. - -## Branch Rendering (`BranchItem`, `MessageBlock`, `use-message-renderer`) -- Preserved our richer branch UI (status labels, collapse previews, raised pill) but adopted upstream support for HTML content blocks, branch graph characters, and validation messaging. -- Made `BranchItem` accept optional `branchChar` so upstream tree computation works without dropping our layout. -- Adjusted `MessageBlock` to accept theme-aware `ThemeColor` values, resolving them to concrete strings internally. -- Converted all uses of OpenTUI `` to the new `style.wrapMode` pattern and reconciled color handling to avoid passing sentinel values directly to the renderer. - -## Login Modal & TerminalLink -- Took upstream improvements (responsive logo via `useLogo`, sheen animation tweaks, light/dark heuristics) and merged with our theme-aware clipboard/keyboard flow. -- Added the new `isLightModeColor` helper expected by upstream tests. -- Updated `TerminalLink` to expose the new `inline` behavior and use `wrapMode` styles for compatibility with latest OpenTUI typings. - -## Validation Messaging -- Adopted upstream validation block generation (`create-validation-error-blocks.tsx`, new tests) and wired it into our initial chat message creation while keeping the themed styling choices. - -## Tooling & Lockfile -- Regenerated `bun.lock` via `bun install` so dependency graph matches upstream package updates. -- Verified CLI typecheck (`bun run typecheck`) and full test suite (`bun test`), ensuring tmux integration passes after resetting any lingering tmux server state. From c862f5977564950bf809b56213ebd9e19b60df91 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 13:45:17 -0800 Subject: [PATCH 24/41] Revert "Merge remote-tracking branch 'origin/main' into brandon/upgrade-opentui-2025-10-28" This reverts commit 6fe5cdeea816953692f0a164b94c5065631b9485, reversing changes made to 65932f56692e00f3e2de8f6f52bd6a16fead25db. --- .../[runId]/steps/__tests__/steps.test.ts | 19 ++---------- .../agent-runs/__tests__/agent-runs.test.ts | 30 +------------------ .../completions/__tests__/completions.test.ts | 17 +---------- 3 files changed, 4 insertions(+), 62 deletions(-) diff --git a/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts b/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts index 0e9c02293..29c8dfa63 100644 --- a/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts +++ b/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts @@ -1,20 +1,16 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' -import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { beforeEach, describe, expect, test } from 'bun:test' import { NextRequest } from 'next/server' import { postAgentRunsSteps } from '../_post' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { - Logger, - LoggerWithContextFn, -} from '@codebuff/common/types/contracts/logger' +import type { Logger } from '@codebuff/common/types/contracts/logger' describe('agentRunsStepsPost', () => { let mockGetUserInfoFromApiKey: GetUserInfoFromApiKeyFn let mockLogger: Logger - let mockLoggerWithContext: LoggerWithContextFn let mockTrackEvent: TrackEventFn let mockDb: any @@ -46,8 +42,6 @@ describe('agentRunsStepsPost', () => { debug: () => {}, } - mockLoggerWithContext = mock(() => mockLogger) - mockTrackEvent = () => {} // Default mock DB with successful operations @@ -79,7 +73,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -104,7 +97,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -129,7 +121,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -154,7 +145,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -190,7 +180,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: dbWithNoRun, }) @@ -226,7 +215,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: dbWithDifferentUser, }) @@ -251,7 +239,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -282,7 +269,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -324,7 +310,6 @@ describe('agentRunsStepsPost', () => { runId: 'run-123', getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: dbWithError, }) diff --git a/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts b/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts index 47dae5c0b..b21796e70 100644 --- a/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts +++ b/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts @@ -10,10 +10,7 @@ import type { GetUserInfoFromApiKeyFn, GetUserInfoFromApiKeyOutput, } from '@codebuff/common/types/contracts/database' -import type { - Logger, - LoggerWithContextFn, -} from '@codebuff/common/types/contracts/logger' +import type { Logger } from '@codebuff/common/types/contracts/logger' describe('/api/v1/agent-runs POST endpoint', () => { const mockUserData: Record< @@ -42,7 +39,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { } let mockLogger: Logger - let mockLoggerWithContext: LoggerWithContextFn let mockTrackEvent: TrackEventFn let mockDb: any @@ -53,7 +49,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { info: mock(() => {}), debug: mock(() => {}), } - mockLoggerWithContext = mock(() => mockLogger) mockTrackEvent = mock(() => {}) @@ -87,7 +82,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -111,7 +105,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -135,7 +128,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -158,7 +150,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -181,7 +172,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -204,7 +194,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -227,7 +216,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -251,7 +239,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -276,7 +263,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -300,7 +286,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -339,7 +324,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -374,7 +358,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -411,7 +394,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -449,7 +431,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -477,7 +458,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -505,7 +485,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -533,7 +512,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -561,7 +539,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -605,7 +582,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -647,7 +623,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -689,7 +664,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -724,7 +698,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) @@ -763,7 +736,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, - loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, db: mockDb, }) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 00e7700ce..c282488f4 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -12,10 +12,7 @@ import type { GetUserInfoFromApiKeyFn, GetUserInfoFromApiKeyOutput, } from '@codebuff/common/types/contracts/database' -import type { - Logger, - LoggerWithContextFn, -} from '@codebuff/common/types/contracts/logger' +import type { Logger } from '@codebuff/common/types/contracts/logger' describe('/api/v1/chat/completions POST endpoint', () => { const mockUserData: Record< @@ -41,7 +38,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { } let mockLogger: Logger - let mockLoggerWithContext: LoggerWithContextFn let mockTrackEvent: TrackEventFn let mockGetUserUsageData: GetUserUsageDataFn let mockGetAgentRunFromId: GetAgentRunFromIdFn @@ -56,8 +52,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { debug: mock(() => {}), } - mockLoggerWithContext = mock(() => mockLogger) - mockTrackEvent = mock(() => {}) mockGetUserUsageData = mock(async ({ userId }: { userId: string }) => { @@ -175,7 +169,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: globalThis.fetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(401) @@ -202,7 +195,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(401) @@ -231,7 +223,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(400) @@ -258,7 +249,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(400) @@ -288,7 +278,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(400) @@ -320,7 +309,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(400) @@ -354,7 +342,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(402) @@ -393,7 +380,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) if (response.status !== 200) { @@ -432,7 +418,6 @@ describe('/api/v1/chat/completions POST endpoint', () => { getAgentRunFromId: mockGetAgentRunFromId, fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, - loggerWithContext: mockLoggerWithContext, }) expect(response.status).toBe(200) From 2052bb0338ffd88ad1c902db0bfed287cdc8b9cf Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 14:32:28 -0800 Subject: [PATCH 25/41] Fix login modal transparency and center positioning - Use solid background colors (white/black) instead of transparent theme.background - Fix light/dark mode detection by checking text color instead of background - Center modal horizontally and vertically in terminal window --- cli/src/components/login-modal.tsx | 38 ++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 3ea6715bb..904174ce8 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -219,15 +219,28 @@ export const LoginModal = ({ } }, [hasOpenedBrowser, loginUrl, copyToClipboard]) - // Determine if we're in light mode by checking background color luminance - const isLightMode = useMemo( - () => isLightModeColor(theme.background), - [theme.background], - ) + // Determine if we're in light mode by checking text colors + // Note: We check text color instead of background because theme.background is 'transparent' + // In light mode: text is dark (#1f2937) + // In dark mode: text is light (#ffffff) + const isLightMode = useMemo(() => { + const textColor = theme.messageAiText + if (textColor && textColor !== 'default' && textColor.startsWith('#')) { + const textIsLight = isLightModeColor(textColor) + // Light text = dark background = dark mode + // Dark text = light background = light mode + return !textIsLight + } + // Fallback to dark mode if we can't determine + return false + }, [theme.messageAiText]) // Use pure black/white for logo const logoColor = isLightMode ? '#000000' : '#ffffff' + // Use solid background colors for the modal (instead of transparent theme.background) + const modalBackground = isLightMode ? '#ffffff' : '#000000' + // Calculate terminal width and height for responsive display const terminalWidth = renderer?.width || 80 const terminalHeight = renderer?.height || 24 @@ -288,20 +301,25 @@ export const LoginModal = ({ WARNING_BANNER_HEIGHT, ) + // Calculate modal width and center position + const modalWidth = Math.floor(terminalWidth * 0.95) + const modalLeft = Math.floor((terminalWidth - modalWidth) / 2) + const modalTop = Math.floor((terminalHeight - modalHeight) / 2) + // Format URL for display (wrap if needed) return ( Date: Mon, 3 Nov 2025 14:49:02 -0800 Subject: [PATCH 26/41] Refactor message block tool branches --- ...{branch-item.tsx => agent-branch-item.tsx} | 38 +-- cli/src/components/message-block.tsx | 15 +- cli/src/components/tool-call-item.tsx | 255 ++++++++++++++++++ 3 files changed, 281 insertions(+), 27 deletions(-) rename cli/src/components/{branch-item.tsx => agent-branch-item.tsx} (98%) create mode 100644 cli/src/components/tool-call-item.tsx diff --git a/cli/src/components/branch-item.tsx b/cli/src/components/agent-branch-item.tsx similarity index 98% rename from cli/src/components/branch-item.tsx rename to cli/src/components/agent-branch-item.tsx index cd0103963..80cd658b6 100644 --- a/cli/src/components/branch-item.tsx +++ b/cli/src/components/agent-branch-item.tsx @@ -1,24 +1,11 @@ import { TextAttributes, type BorderCharacters } from '@opentui/core' import React, { type ReactNode } from 'react' -const containerBorderChars: BorderCharacters = { - topLeft: '╭', - topRight: '╮', - bottomLeft: '╰', - bottomRight: '╯', - horizontal: '─', - vertical: '│', - topT: '┬', - bottomT: '┴', - leftT: '├', - rightT: '┤', - cross: '┼', -} +import { RaisedPill } from './raised-pill' import type { ChatTheme } from '../utils/theme-system' -import { RaisedPill } from './raised-pill' -interface BranchItemProps { +interface AgentBranchItemProps { name: string content: ReactNode prompt?: string @@ -38,7 +25,21 @@ interface BranchItemProps { titleSuffix?: string } -export const BranchItem = ({ +const containerBorderChars: BorderCharacters = { + topLeft: '╭', + topRight: '╮', + bottomLeft: '╰', + bottomRight: '╯', + horizontal: '─', + vertical: '│', + topT: '┬', + bottomT: '┴', + leftT: '├', + rightT: '┤', + cross: '┼', +} + +export const AgentBranchItem = ({ name, content, prompt, @@ -56,7 +57,7 @@ export const BranchItem = ({ showBorder = true, toggleEnabled = true, titleSuffix, -}: BranchItemProps) => { +}: AgentBranchItemProps) => { const resolveFg = ( color?: string | null, fallback?: string | null, @@ -99,8 +100,7 @@ export const BranchItem = ({ : `${statusIndicator} ${statusLabel}` : null const showCollapsedPreview = - (isStreaming && !!streamingPreview) || - (!isStreaming && !!finishedPreview) + (isStreaming && !!streamingPreview) || (!isStreaming && !!finishedPreview) const isTextRenderable = (value: ReactNode): boolean => { if (value === null || value === undefined || typeof value === 'boolean') { diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 0e32a7d92..aa7acc4c8 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -4,7 +4,8 @@ import stringWidth from 'string-width' import { pluralize } from '@codebuff/common/util/string' -import { BranchItem } from './branch-item' +import { AgentBranchItem } from './agent-branch-item' +import { ToolCallItem } from './tool-call-item' import { getToolDisplayInfo } from '../utils/codebuff-client' import { getToolRenderConfig } from './tool-renderer' import { @@ -161,7 +162,7 @@ export const MessageBlock = ({ } const displayInfo = getToolDisplayInfo(toolBlock.toolName) - const isCollapsed = true + const isCollapsed = collapsedAgents.has(toolBlock.toolCallId) const isStreaming = streamingAgents.has(toolBlock.toolCallId) const inputContent = `\`\`\`json\n${JSON.stringify(toolBlock.input, null, 2)}\n\`\`\`` @@ -271,18 +272,16 @@ export const MessageBlock = ({ key={keyPrefix} ref={(el: any) => registerAgentRef(toolBlock.toolCallId, el)} > - onToggleCollapsed(toolBlock.toolCallId)} titleSuffix={toolRenderConfig.path} /> @@ -349,7 +348,7 @@ export const MessageBlock = ({ ref={(el: any) => registerAgentRef(agentBlock.agentId, el)} style={{ flexDirection: 'column', gap: 0 }} > - registerAgentRef(agentListBlock.id, el)} > - void + titleSuffix?: string +} + +const isTextRenderable = (value: ReactNode): boolean => { + if (value === null || value === undefined || typeof value === 'boolean') { + return false + } + + if (typeof value === 'string' || typeof value === 'number') { + return true + } + + if (Array.isArray(value)) { + return value.every((child) => isTextRenderable(child)) + } + + if (React.isValidElement(value)) { + if (value.type === React.Fragment) { + return isTextRenderable(value.props.children) + } + + if (typeof value.type === 'string') { + if ( + value.type === 'span' || + value.type === 'strong' || + value.type === 'em' + ) { + return isTextRenderable(value.props.children) + } + + return false + } + } + + return false +} + +const renderExpandedContent = ( + value: ReactNode, + theme: ChatTheme, + fallbackTextColor: string, + getAttributes: (extra?: number) => number | undefined, +): ReactNode => { + if ( + value === null || + value === undefined || + value === false || + value === true + ) { + return null + } + + if (isTextRenderable(value)) { + return ( + + {value} + + ) + } + + if (React.isValidElement(value)) { + if (value.key === null || value.key === undefined) { + return ( + + {value} + + ) + } + return value + } + + if (Array.isArray(value)) { + return ( + + {value.map((child, idx) => ( + + {child} + + ))} + + ) + } + + return ( + + {value} + + ) +} + +export const ToolCallItem = ({ + name, + content, + isCollapsed, + isStreaming, + branchChar, + streamingPreview, + finishedPreview, + theme, + onToggle, + titleSuffix, +}: ToolCallItemProps) => { + const resolveFg = ( + color?: string | null, + fallback?: string | null, + ): string | undefined => { + if (color && color !== 'default') return color + if (fallback && fallback !== 'default') return fallback + return undefined + } + + const fallbackTextColor = + resolveFg(theme.agentContentText) ?? + resolveFg(theme.chromeText) ?? + '#d1d5e5' + + const baseTextAttributes = theme.messageTextAttributes ?? 0 + const getAttributes = (extra: number = 0): number | undefined => { + const combined = baseTextAttributes | extra + return combined === 0 ? undefined : combined + } + + const isExpanded = !isCollapsed + const toggleLabelColor = theme.chromeText ?? theme.agentToggleHeaderBg + const toggleIndicator = onToggle ? (isCollapsed ? '▸ ' : '▾ ') : '' + const toggleLabel = `${branchChar}${toggleIndicator}` + const toggleLabelFg = resolveFg(toggleLabelColor, fallbackTextColor) + const headerFg = resolveFg(theme.agentToggleHeaderText, fallbackTextColor) + const collapsedPreviewText = isStreaming ? streamingPreview : finishedPreview + const showCollapsedPreview = collapsedPreviewText.length > 0 + + return ( + + + + + + {toggleLabel} + + + {name} + + {titleSuffix ? ( + + {` ${titleSuffix}`} + + ) : null} + {isStreaming ? ( + + {' running'} + + ) : null} + + + + {isCollapsed ? ( + showCollapsedPreview ? ( + + + {collapsedPreviewText} + + + ) : null + ) : ( + + {renderExpandedContent( + content, + theme, + fallbackTextColor ?? '#d1d5e5', + getAttributes, + )} + + )} + + + ) +} From 8ae1ad442d3bb12da7206d3729906feb285aaba9 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 15:17:28 -0800 Subject: [PATCH 27/41] 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. --- cli/src/chat.tsx | 56 +-- .../__tests__/status-indicator.timer.test.tsx | 43 +-- cli/src/components/agent-mode-toggle.tsx | 31 +- cli/src/components/login-modal.tsx | 50 ++- cli/src/components/multiline-input.tsx | 14 +- cli/src/components/separator.tsx | 7 +- cli/src/components/status-indicator.tsx | 5 +- cli/src/components/suggestion-menu.tsx | 5 +- cli/src/hooks/use-theme.tsx | 195 ++++++++++ cli/src/index.tsx | 7 +- cli/src/utils/theme-config.ts | 335 ++++++++++++++++++ cli/src/utils/theme-system.ts | 21 +- 12 files changed, 636 insertions(+), 133 deletions(-) create mode 100644 cli/src/hooks/use-theme.tsx create mode 100644 cli/src/utils/theme-config.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 25b3e9491..8a6ad3c9b 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -36,6 +36,7 @@ import { useMessageRenderer } from './hooks/use-message-renderer' import { useChatScrollbox } from './hooks/use-scroll-management' import { useSendMessage } from './hooks/use-send-message' import { useSuggestionEngine } from './hooks/use-suggestion-engine' +import { useTheme, useResolvedThemeName } from './hooks/use-theme' import { useChatStore } from './state/chat-store' import { flushAnalytics } from './utils/analytics' import { getUserCredentials } from './utils/auth' @@ -50,9 +51,7 @@ import { logger } from './utils/logger' import { buildMessageTree } from './utils/message-tree-utils' import { handleSlashCommands } from './utils/slash-commands' import { - chatTheme, createMarkdownPalette, - onThemeChange, resolveThemeColor, type ChatTheme, } from './utils/theme-system' @@ -170,42 +169,9 @@ export const App = ({ const terminalWidth = resolvedTerminalWidth const separatorWidth = Math.max(1, Math.floor(terminalWidth) - 2) - const cloneTheme = (input: ChatTheme): ChatTheme => ({ - ...input, - markdown: input.markdown - ? { - ...input.markdown, - headingFg: input.markdown.headingFg - ? { ...input.markdown.headingFg } - : undefined, - } - : undefined, - }) - - const [theme, setTheme] = useState(() => cloneTheme(chatTheme)) - const [resolvedThemeName, setResolvedThemeName] = useState<'dark' | 'light'>( - chatTheme.messageTextAttributes ? 'dark' : 'light', - ) - - useEffect(() => { - const unsubscribe = onThemeChange((updatedTheme, meta) => { - const nextTheme = cloneTheme(updatedTheme) - setTheme(nextTheme) - setResolvedThemeName(meta.resolvedThemeName) - if (process.env.CODEBUFF_THEME_DEBUG === '1') { - logger.debug( - { - themeChange: { - source: meta.source, - resolvedThemeName: meta.resolvedThemeName, - }, - }, - 'Applied theme change in chat component', - ) - } - }) - return unsubscribe - }, []) + // Use theme hooks (transparent variant is default) + const theme = useTheme() + const resolvedThemeName = useResolvedThemeName() const markdownPalette = useMemo( () => createMarkdownPalette(theme), @@ -316,8 +282,6 @@ export const App = ({ ? theme.chromeText : theme.agentResponseCount - const logoColor = resolvedThemeName === 'dark' ? '#4ade80' : '#15803d' - const homeDir = os.homedir() const repoRoot = path.dirname(loadedAgentsData.agentsDir) const relativePath = path.relative(homeDir, repoRoot) @@ -334,7 +298,7 @@ export const App = ({ { type: 'text', content: '\n\n' + logoBlock, - color: logoColor, + color: theme.logoColor, }, ] @@ -1173,7 +1137,6 @@ export const App = ({ const statusIndicatorNode = ( )} - + {slashContext.active && slashSuggestionItems.length > 0 ? ( @@ -1404,7 +1366,6 @@ export const App = ({ @@ -1424,7 +1385,6 @@ export const App = ({ placeholder="Share your thoughts and press Enter…" focused={inputFocused} maxHeight={5} - theme={theme} width={inputWidth} onKeyIntercept={handleSuggestionMenuKey} textAttributes={theme.messageTextAttributes} @@ -1439,19 +1399,17 @@ export const App = ({ > - + {/* Login Modal Overlay - show when not authenticated and done checking */} {requireAuth !== null && isAuthenticated === false && ( )} diff --git a/cli/src/components/__tests__/status-indicator.timer.test.tsx b/cli/src/components/__tests__/status-indicator.timer.test.tsx index 2336ca77e..28a52e946 100644 --- a/cli/src/components/__tests__/status-indicator.timer.test.tsx +++ b/cli/src/components/__tests__/status-indicator.timer.test.tsx @@ -13,12 +13,10 @@ import { } from 'bun:test' import { StatusIndicator } from '../status-indicator' -import { chatThemes } from '../../utils/theme-system' +import { ThemeProvider } from '../../hooks/use-theme' import { renderToStaticMarkup } from 'react-dom/server' import * as codebuffClient from '../../utils/codebuff-client' -const theme = chatThemes.dark - const createTimer = (elapsedSeconds: number, started: boolean) => ({ start: () => {}, stop: () => {}, @@ -41,23 +39,25 @@ describe('StatusIndicator timer rendering', () => { test('shows elapsed seconds when timer is active', () => { const markup = renderToStaticMarkup( - , + + + , ) expect(markup).toContain('5s') const inactiveMarkup = renderToStaticMarkup( - , + + + , ) expect(inactiveMarkup).toBe('') @@ -65,12 +65,13 @@ describe('StatusIndicator timer rendering', () => { test('clipboard message takes priority over timer output', () => { const markup = renderToStaticMarkup( - , + + + , ) expect(markup).toContain('Copied!') diff --git a/cli/src/components/agent-mode-toggle.tsx b/cli/src/components/agent-mode-toggle.tsx index 00c8ad439..6836630ee 100644 --- a/cli/src/components/agent-mode-toggle.tsx +++ b/cli/src/components/agent-mode-toggle.tsx @@ -1,20 +1,33 @@ -import { AgentMode } from '../utils/constants' -import type { ChatTheme } from '../utils/theme-system' import { RaisedPill } from './raised-pill' +import { useTheme } from '../hooks/use-theme' + +import type { AgentMode } from '../utils/constants' +import type { ChatTheme } from '../utils/theme-system' + +const getModeConfig = (theme: ChatTheme) => + ({ + FAST: { + frameColor: theme.modeToggleFastBg, + textColor: theme.modeToggleFastText, + label: 'FAST', + }, + MAX: { + frameColor: theme.modeToggleMaxBg, + textColor: theme.modeToggleMaxText, + label: '💪 MAX', + }, + }) as const export const AgentModeToggle = ({ mode, - theme, onToggle, }: { - mode: AgentMode, - theme: ChatTheme + mode: AgentMode onToggle: () => void }) => { - const isFast = mode === 'FAST' - const frameColor = isFast ? theme.modeToggleFastBg : theme.modeToggleMaxBg - const textColor = isFast ? theme.modeToggleFastText : theme.modeToggleMaxText - const label = isFast ? 'FAST' : '💪 MAX' + const theme = useTheme() + const config = getModeConfig(theme) + const { frameColor, textColor, label } = config[mode] return ( void - theme: ChatTheme hasInvalidCredentials?: boolean | null } export const LoginModal = ({ onLoginSuccess, - theme, hasInvalidCredentials = false, +}: LoginModalProps) => { + return ( + + + + ) +} + +const LoginModalContent = ({ + onLoginSuccess, + hasInvalidCredentials, }: LoginModalProps) => { const renderer = useRenderer() + // Use theme from context (will be modal variant due to VariantProvider) + const theme = useTheme() + // Use zustand store for all state const { loginUrl, @@ -219,28 +233,6 @@ export const LoginModal = ({ } }, [hasOpenedBrowser, loginUrl, copyToClipboard]) - // Determine if we're in light mode by checking text colors - // Note: We check text color instead of background because theme.background is 'transparent' - // In light mode: text is dark (#1f2937) - // In dark mode: text is light (#ffffff) - const isLightMode = useMemo(() => { - const textColor = theme.messageAiText - if (textColor && textColor !== 'default' && textColor.startsWith('#')) { - const textIsLight = isLightModeColor(textColor) - // Light text = dark background = dark mode - // Dark text = light background = light mode - return !textIsLight - } - // Fallback to dark mode if we can't determine - return false - }, [theme.messageAiText]) - - // Use pure black/white for logo - const logoColor = isLightMode ? '#000000' : '#ffffff' - - // Use solid background colors for the modal (instead of transparent theme.background) - const modalBackground = isLightMode ? '#ffffff' : '#000000' - // Calculate terminal width and height for responsive display const terminalWidth = renderer?.width || 80 const terminalHeight = renderer?.height || 24 @@ -278,7 +270,7 @@ export const LoginModal = ({ // Use custom hook for sheen animation const { applySheenToChar } = useSheenAnimation({ - logoColor, + logoColor: theme.logoColor, terminalWidth: renderer?.width, sheenPosition, setSheenPosition, @@ -319,7 +311,7 @@ export const LoginModal = ({ width: modalWidth, height: modalHeight, maxHeight: modalHeight, - backgroundColor: modalBackground, + backgroundColor: theme.background, padding: 0, flexDirection: 'column', }} @@ -352,7 +344,7 @@ export const LoginModal = ({ alignItems: 'center', width: '100%', height: '100%', - backgroundColor: modalBackground, + backgroundColor: theme.background, padding: containerPadding, gap: 0, }} diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 8fe0a7cbb..b807a993b 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -11,9 +11,9 @@ import { } from 'react' import { useOpentuiPaste } from '../hooks/use-opentui-paste' +import { useTheme } from '../hooks/use-theme' import type { PasteEvent, ScrollBoxRenderable } from '@opentui/core' -import type { ChatTheme } from '../utils/theme-system' // Helper functions for text manipulation function findLineStart(text: string, cursor: number): number { @@ -82,16 +82,6 @@ interface MultilineInputProps { placeholder?: string focused?: boolean maxHeight?: number - theme: Pick< - ChatTheme, - | 'inputBg' - | 'inputFocusedBg' - | 'inputFg' - | 'inputFocusedFg' - | 'inputPlaceholder' - | 'cursor' - | 'statusAccent' - > width: number textAttributes?: number } @@ -111,13 +101,13 @@ export const MultilineInput = forwardRef< placeholder = '', focused = true, maxHeight = 5, - theme, width, textAttributes, onKeyIntercept, }: MultilineInputProps, forwardedRef, ) { + const theme = useTheme() const scrollBoxRef = useRef(null) const [cursorPosition, setCursorPosition] = useState(value.length) useImperativeHandle( diff --git a/cli/src/components/separator.tsx b/cli/src/components/separator.tsx index cae7ee192..fa7fdef06 100644 --- a/cli/src/components/separator.tsx +++ b/cli/src/components/separator.tsx @@ -1,13 +1,14 @@ import React from 'react' -import type { ChatTheme } from '../utils/theme-system' +import { useTheme } from '../hooks/use-theme' interface SeparatorProps { - theme: ChatTheme width: number } -export const Separator = ({ theme, width }: SeparatorProps) => { +export const Separator = ({ width }: SeparatorProps) => { + const theme = useTheme() + return ( { const [isConnected, setIsConnected] = useState(true) @@ -37,16 +37,15 @@ const useConnectionStatus = () => { } export const StatusIndicator = ({ - theme, clipboardMessage, isActive = false, timer, }: { - theme: ChatTheme clipboardMessage?: string | null isActive?: boolean timer: ElapsedTimeTracker }) => { + const theme = useTheme() const isConnected = useConnectionStatus() const elapsedSeconds = timer.elapsedSeconds diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index f388748e7..95f8d3cde 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -1,6 +1,6 @@ import React from 'react' -import type { ChatTheme } from '../utils/theme-system' +import { useTheme } from '../hooks/use-theme' export interface SuggestionItem { id: string @@ -11,7 +11,6 @@ export interface SuggestionItem { interface SuggestionMenuProps { items: SuggestionItem[] selectedIndex: number - theme: ChatTheme maxVisible?: number prefix?: string } @@ -19,10 +18,10 @@ interface SuggestionMenuProps { export const SuggestionMenu = ({ items, selectedIndex, - theme, maxVisible = 5, prefix = '/', }: SuggestionMenuProps) => { + const theme = useTheme() const resolveFg = ( color?: string | null, fallback?: string | null, diff --git a/cli/src/hooks/use-theme.tsx b/cli/src/hooks/use-theme.tsx new file mode 100644 index 000000000..7fed0657f --- /dev/null +++ b/cli/src/hooks/use-theme.tsx @@ -0,0 +1,195 @@ +/** + * Theme Hooks and Context + * + * Provides hook-based API for accessing themes with variant support + */ + +import React, { + createContext, + useContext, + useState, + useEffect, + useMemo, + useCallback, +} from 'react' + +import { chatTheme, onThemeChange, cloneChatTheme } from '../utils/theme-system' +import type { ChatTheme } from '../utils/theme-system' +import type { ThemeVariant } from '../utils/theme-config' +import { + getVariantConfig, + themeConfig, + buildThemeWithVariant, +} from '../utils/theme-config' + +/** + * Theme context value + */ +interface ThemeContextValue { + /** Base theme from auto-detection */ + baseTheme: ChatTheme + /** Resolved theme name (dark or light) */ + resolvedThemeName: 'dark' | 'light' + /** Build a theme for a specific variant */ + buildVariantTheme: (variant: ThemeVariant) => ChatTheme +} + +/** + * Theme context + */ +const ThemeContext = createContext(null) + +/** + * Variant context for nested components + * Allows parent components to set variant for their children + */ +const VariantContext = createContext('transparent') + +/** + * Theme Provider Props + */ +interface ThemeProviderProps { + children: React.ReactNode +} + +/** + * Theme Provider Component + * Wraps app and provides theme context with variant support + */ +export const ThemeProvider: React.FC = ({ children }) => { + // Track base theme and resolved name + const [baseTheme, setBaseTheme] = useState(() => + cloneChatTheme(chatTheme), + ) + const [resolvedThemeName, setResolvedThemeName] = useState<'dark' | 'light'>( + 'light', + ) + + // Subscribe to theme changes from auto-detection + useEffect(() => { + const unsubscribe = onThemeChange((updatedTheme, meta) => { + setBaseTheme(cloneChatTheme(updatedTheme)) + setResolvedThemeName(meta.resolvedThemeName) + }) + return unsubscribe + }, []) + + /** + * Build a theme for a specific variant + * Applies all theme layers: backgrounds, config overrides, custom colors, and plugins + */ + const buildVariantTheme = useCallback( + (variant: ThemeVariant): ChatTheme => { + const variantConfig = getVariantConfig(variant) + const clonedTheme = cloneChatTheme(baseTheme) + + return buildThemeWithVariant( + clonedTheme, + variant, + variantConfig, + resolvedThemeName, + themeConfig.customColors, + themeConfig.plugins, + ) + }, + [baseTheme, resolvedThemeName], + ) + + const contextValue = useMemo( + () => ({ + baseTheme, + resolvedThemeName, + buildVariantTheme, + }), + [baseTheme, resolvedThemeName, buildVariantTheme], + ) + + return ( + + {children} + + ) +} + +/** + * Hook to access theme for the current component context + * Returns the theme variant set by the nearest parent component + * or the default transparent variant if none is set + * + * @returns Theme object for the current context + * + * @example + * // In a regular component (gets transparent theme) + * const theme = useTheme() + * + * @example + * // Inside a ModalVariant component (gets modal theme with solid backgrounds) + * const theme = useTheme() + */ +export const useTheme = (): ChatTheme => { + const context = useContext(ThemeContext) + const variant = useContext(VariantContext) + + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider') + } + + // Memoize theme for this variant to avoid rebuilding on every render + const theme = useMemo( + () => context.buildVariantTheme(variant), + [context, variant], + ) + + return theme +} + +/** + * Hook to access the resolved theme name (dark or light) + * @returns 'dark' or 'light' based on auto-detection + * + * @example + * const themeName = useResolvedThemeName() + * const logoColor = themeName === 'dark' ? '#ffffff' : '#000000' + */ +export const useResolvedThemeName = (): 'dark' | 'light' => { + const context = useContext(ThemeContext) + + if (!context) { + throw new Error('useResolvedThemeName must be used within a ThemeProvider') + } + + return context.resolvedThemeName +} + +/** + * Theme Variant Provider Props + */ +interface VariantProviderProps { + variant: ThemeVariant + children: React.ReactNode +} + +/** + * Theme Variant Provider Component + * Sets the theme variant for all children components + * Use this in base components (like BaseModal) to apply variant-specific theming + * + * @example + * export const BaseModal = ({ children }) => ( + * + * + * {children} + * + * + * ) + */ +export const VariantProvider: React.FC = ({ + variant, + children, +}) => { + return ( + + {children} + + ) +} diff --git a/cli/src/index.tsx b/cli/src/index.tsx index ec326ed48..78043491c 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -11,6 +11,7 @@ import React from 'react' import { validateAgents } from '@codebuff/sdk' import { App } from './chat' +import { ThemeProvider } from './hooks/use-theme' import { getUserCredentials } from './utils/auth' import { getLoadedAgentsData } from './utils/local-agent-registry' import { clearLogFile } from './utils/logger' @@ -144,11 +145,13 @@ const AppWithAsyncAuth = () => { ) } -// Start app immediately with QueryClientProvider +// Start app immediately with QueryClientProvider and ThemeProvider function startApp() { render( - + + + , { backgroundColor: 'transparent', diff --git a/cli/src/utils/theme-config.ts b/cli/src/utils/theme-config.ts new file mode 100644 index 000000000..0b2cf5a8d --- /dev/null +++ b/cli/src/utils/theme-config.ts @@ -0,0 +1,335 @@ +/** + * Theme Configuration System + * + * Tailwind-inspired theme configuration that allows components to use different + * theme variants (transparent, modal, embedded, custom) while maintaining the + * automatic light/dark mode detection. + */ + +import type { ChatTheme } from './theme-system' + +/** + * Theme variant types for different component use cases + * - transparent: Default transparent backgrounds (terminal shows through) + * - modal: Solid backgrounds for overlay components like LoginModal + * - embedded: For future embedded views that need controlled backgrounds + * - custom: User-defined custom variant + */ +export type ThemeVariant = 'transparent' | 'modal' | 'embedded' | 'custom' + +/** + * Background color configuration for a theme variant + * Use 'auto' to automatically use #ffffff (light mode) or #000000 (dark mode) + * Use 'transparent' to keep transparent + * Use a hex color string for custom colors + */ +export type BackgroundColor = 'auto' | 'transparent' | string + +/** + * Configuration for background colors in a theme variant + */ +export interface ThemeVariantBackgrounds { + /** Main background color (replaces theme.background) */ + main?: BackgroundColor + /** Chrome background color (replaces theme.chromeBg) */ + chrome?: BackgroundColor + /** Panel background color (replaces theme.panelBg) */ + panel?: BackgroundColor + /** Message background color (replaces theme.messageBg) */ + message?: BackgroundColor + /** Input background color (replaces theme.inputBg) */ + input?: BackgroundColor + /** Focused input background color (replaces theme.inputFocusedBg) */ + inputFocused?: BackgroundColor + /** Agent content background color (replaces theme.agentContentBg) */ + agent?: BackgroundColor + /** Accent background color (replaces theme.accentBg) */ + accent?: BackgroundColor + /** Agent focused background (replaces theme.agentFocusedBg) */ + agentFocused?: BackgroundColor + /** Agent toggle header background (replaces theme.agentToggleHeaderBg) */ + agentToggleHeader?: BackgroundColor + /** Agent toggle expanded background (replaces theme.agentToggleExpandedBg) */ + agentToggleExpanded?: BackgroundColor + /** Markdown code background (replaces theme.markdown?.codeBackground) */ + markdownCode?: BackgroundColor +} + +/** + * Configuration for a single theme variant + */ +export interface ThemeVariantConfig { + /** Background color overrides */ + backgrounds?: ThemeVariantBackgrounds + /** Additional theme property overrides */ + overrides?: Partial +} + +/** + * Plugin interface for extending theme system + * Plugins can modify themes at runtime + */ +export interface ThemePlugin { + /** Unique plugin name */ + name: string + /** + * Apply plugin modifications to a theme + * @param theme - The base theme + * @param variant - The current variant being built + * @param mode - The detected light/dark mode + * @returns Partial theme to merge + */ + apply: ( + theme: ChatTheme, + variant: ThemeVariant, + mode: 'dark' | 'light', + ) => Partial +} + +/** + * Main theme configuration interface + */ +export interface ThemeConfig { + /** Built-in theme variants */ + variants: Record + /** Global color overrides applied to all variants */ + customColors?: Partial + /** Registered plugins for theme extensions */ + plugins?: ThemePlugin[] +} + +/** + * Default theme configuration + * This is the base configuration that can be extended by users + */ +export const defaultThemeConfig: ThemeConfig = { + variants: { + /** + * Transparent variant (default) + * All backgrounds are transparent, terminal background shows through + * This is the current default behavior + */ + transparent: { + backgrounds: { + // All backgrounds remain transparent (no overrides) + }, + }, + + /** + * Modal variant + * Solid backgrounds for overlay components + * Use 'auto' to get white in light mode, black in dark mode + */ + modal: { + backgrounds: { + main: 'auto', + chrome: 'auto', + panel: 'auto', + message: 'auto', + input: 'auto', + inputFocused: 'auto', + agent: 'auto', + accent: 'auto', + agentFocused: 'auto', + agentToggleHeader: 'auto', + markdownCode: 'auto', + }, + }, + + /** + * Embedded variant + * For future embedded views that need controlled backgrounds + * Similar to modal but with more selective solid backgrounds + */ + embedded: { + backgrounds: { + main: 'auto', + chrome: 'auto', + panel: 'auto', + }, + }, + + /** + * Custom variant + * Placeholder for user-defined custom themes + * Can be overridden via customColors in ThemeConfig + */ + custom: { + backgrounds: {}, + }, + }, + + // Global overrides (applied to all variants) + customColors: {}, + + // Plugins (empty by default) + plugins: [], +} + +/** + * Active theme configuration + * Can be modified at runtime for customization + */ +export let themeConfig: ThemeConfig = defaultThemeConfig + +/** + * Update the active theme configuration + * @param config - New configuration (will be merged with defaults) + */ +export const setThemeConfig = (config: Partial): void => { + themeConfig = { + ...defaultThemeConfig, + ...config, + variants: { + ...defaultThemeConfig.variants, + ...config.variants, + }, + plugins: [...(defaultThemeConfig.plugins ?? []), ...(config.plugins ?? [])], + } +} + +/** + * Register a theme plugin + * @param plugin - Plugin to register + */ +export const registerThemePlugin = (plugin: ThemePlugin): void => { + if (!themeConfig.plugins) { + themeConfig.plugins = [] + } + // Check if plugin already registered + if (themeConfig.plugins.some((p) => p.name === plugin.name)) { + console.warn(`Theme plugin "${plugin.name}" is already registered`) + return + } + themeConfig.plugins.push(plugin) +} + +/** + * Get configuration for a specific variant + * @param variant - The variant to get config for + * @returns The variant configuration + */ +export const getVariantConfig = (variant: ThemeVariant): ThemeVariantConfig => { + return themeConfig.variants[variant] ?? themeConfig.variants.transparent +} + +/** + * Resolve a background color based on mode + * Converts 'auto' to white (light) or black (dark) + * @param color - Background color specification + * @param mode - Current theme mode (dark or light) + * @returns Resolved color string + */ +export const resolveBackgroundColor = ( + color: BackgroundColor | undefined, + mode: 'dark' | 'light', +): string | undefined => { + if (!color) return undefined + if (color === 'transparent') return 'transparent' + if (color === 'auto') { + return mode === 'dark' ? '#000000' : '#ffffff' + } + return color +} + +/** + * Mapping of theme properties to their corresponding background config keys + * Makes it easy to apply all background overrides without repetition + */ +const BACKGROUND_PROPERTY_MAPPING: Array< + [keyof ChatTheme, keyof ThemeVariantBackgrounds] +> = [ + ['background', 'main'], + ['chromeBg', 'chrome'], + ['panelBg', 'panel'], + ['messageBg', 'message'], + ['inputBg', 'input'], + ['inputFocusedBg', 'inputFocused'], + ['agentContentBg', 'agent'], + ['accentBg', 'accent'], + ['agentFocusedBg', 'agentFocused'], + ['agentToggleHeaderBg', 'agentToggleHeader'], + ['agentToggleExpandedBg', 'agentToggleExpanded'], +] + +/** + * Apply variant background overrides to a theme + * Resolves all 'auto' values based on the current light/dark mode + * @param theme - Base theme to apply backgrounds to + * @param variantConfig - Variant configuration with background overrides + * @param mode - Current theme mode (dark or light) + */ +export const applyVariantBackgrounds = ( + theme: ChatTheme, + variantConfig: ThemeVariantConfig, + mode: 'dark' | 'light', +): void => { + if (!variantConfig.backgrounds) return + + const bg = variantConfig.backgrounds + + // Apply all standard background properties via mapping + for (const [themeProp, bgProp] of BACKGROUND_PROPERTY_MAPPING) { + const bgValue = bg[bgProp] + if (bgValue !== undefined) { + const resolved = resolveBackgroundColor(bgValue, mode) + if (resolved !== undefined) { + ;(theme as any)[themeProp] = resolved + } + } + } + + // Handle markdown code background (nested property requires special handling) + if (bg.markdownCode !== undefined && theme.markdown) { + const resolved = resolveBackgroundColor(bg.markdownCode, mode) + if (resolved !== undefined) { + theme.markdown.codeBackground = resolved + } + } +} + +/** + * Build a complete theme by layering overrides + * Applies variant backgrounds, config overrides, custom colors, and plugins + * @param baseTheme - The base theme to start from + * @param variant - Theme variant to apply + * @param variantConfig - Configuration for the variant + * @param mode - Current theme mode (dark or light) + * @param customColors - Optional custom color overrides + * @param plugins - Optional theme plugins to apply + * @returns Complete theme with all layers applied + */ +export const buildThemeWithVariant = ( + baseTheme: ChatTheme, + variant: ThemeVariant, + variantConfig: ThemeVariantConfig, + mode: 'dark' | 'light', + customColors?: Partial, + plugins?: ThemePlugin[], +): ChatTheme => { + // Start with cloned base theme (cloning handled by caller to avoid circular dependency) + const theme = { ...baseTheme } + + // Layer 1: Apply variant background overrides + applyVariantBackgrounds(theme, variantConfig, mode) + + // Layer 2: Apply variant-specific overrides + if (variantConfig.overrides) { + Object.assign(theme, variantConfig.overrides) + } + + // Layer 3: Apply global custom colors + if (customColors) { + Object.assign(theme, customColors) + } + + // Layer 4: Apply plugins + if (plugins) { + for (const plugin of plugins) { + const pluginOverrides = plugin.apply(theme, variant, mode) + Object.assign(theme, pluginOverrides) + } + } + + return theme +} diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 47560826b..6278e0132 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -69,6 +69,7 @@ export interface ChatTheme { modeToggleFastText: string modeToggleMaxBg: string modeToggleMaxText: string + logoColor: string markdown?: { headingFg?: Partial> inlineCodeFg?: string @@ -136,6 +137,7 @@ const NEUTRAL_THEME: ChatTheme = { modeToggleFastText: '#f97316', modeToggleMaxBg: '#dc2626', modeToggleMaxText: '#dc2626', + logoColor: '#2563eb', // Will be overridden based on light/dark mode markdown: { codeBackground: 'transparent', codeHeaderFg: '#475569', @@ -244,6 +246,7 @@ const applyNeutralTextDefaults = ( adjustedTheme.agentContentText = '#dbeafe' adjustedTheme.agentToggleHeaderText = '#ffffff' adjustedTheme.agentToggleText = '#ffffff' + adjustedTheme.logoColor = '#ffffff' adjustedTheme.timestampAi = DARK_VARIANT_OVERRIDES.timestampAi adjustedTheme.timestampUser = DARK_VARIANT_OVERRIDES.timestampUser adjustedTheme.aiLine = DARK_VARIANT_OVERRIDES.aiLine @@ -271,6 +274,7 @@ const applyNeutralTextDefaults = ( adjustedTheme.agentContentText = neutrals.secondary adjustedTheme.agentToggleHeaderText = neutrals.primary adjustedTheme.agentToggleText = neutrals.primary + adjustedTheme.logoColor = '#000000' adjustedTheme.timestampAi = LIGHT_VARIANT_OVERRIDES.timestampAi adjustedTheme.timestampUser = LIGHT_VARIANT_OVERRIDES.timestampUser adjustedTheme.aiLine = LIGHT_VARIANT_OVERRIDES.aiLine @@ -570,7 +574,14 @@ const MAC_TERMINAL_THEME_OVERRIDES: Record<'dark' | 'light', Partial> }, } -const mergeThemeOverrides = ( +/** + * Merge theme overrides with a base theme + * Properly handles nested markdown configuration + * @param base - Base theme to merge into + * @param overrides - Partial theme overrides + * @returns Merged theme + */ +export const mergeThemeOverrides = ( base: ChatTheme, overrides: Partial, ): ChatTheme => { @@ -764,7 +775,13 @@ const computeTheme = (): ThemeComputationMeta => { } } -const cloneChatTheme = (input: ChatTheme): ChatTheme => ({ +/** + * Clone a ChatTheme object to avoid mutations + * Properly handles nested markdown configuration + * @param input - Theme to clone + * @returns Cloned theme + */ +export const cloneChatTheme = (input: ChatTheme): ChatTheme => ({ ...input, markdown: input.markdown ? { From fa475257b1de37c50dcc55869224f0f9c20666d3 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 15:40:58 -0800 Subject: [PATCH 28/41] Upgrade OpenTUI to v0.1.33 --- bun.lock | 22 ++++++++++++---------- cli/package.json | 4 ++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 085bc7306..9bba4e1d7 100644 --- a/bun.lock +++ b/bun.lock @@ -84,8 +84,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "0.1.31", - "@opentui/react": "0.1.31", + "@opentui/core": "0.1.33", + "@opentui/react": "0.1.33", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", @@ -1026,21 +1026,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], - "@opentui/core": ["@opentui/core@0.1.31", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.31", "@opentui/core-darwin-x64": "0.1.31", "@opentui/core-linux-arm64": "0.1.31", "@opentui/core-linux-x64": "0.1.31", "@opentui/core-win32-arm64": "0.1.31", "@opentui/core-win32-x64": "0.1.31", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": ">=0.26.0" } }, "sha512-Q6nL0WFkDDjl3mibdSPppOJbU5mr2f/0iC1+GvydiSvi/iv4CGxaTu6oPyUOK5BVv8ujWFzQ0sR7rc6yv7Jr+Q=="], + "@opentui/core": ["@opentui/core@0.1.33", "", { "dependencies": { "bun-ffi-structs": "^0.1.0", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.33", "@opentui/core-darwin-x64": "0.1.33", "@opentui/core-linux-arm64": "0.1.33", "@opentui/core-linux-x64": "0.1.33", "@opentui/core-win32-arm64": "0.1.33", "@opentui/core-win32-x64": "0.1.33", "bun-webgpu": "0.1.3", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vwHdrPIqnsY6YnG2JTNhenHSsx+HUPYrQTBZdmEfCj9ROGVzKgUKbSDH1xGK2OtSNRb2KVBg4XaMpq0bie6afQ=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.31", "", { "os": "darwin", "cpu": "arm64" }, "sha512-irsQW6XUAwJ5YkWH3OHrAD3LX7MN36RWkNQbUh2/pYCRUa4+bdsh6esFv7eXnDt/fUKAQ+tNtw/6jCo7I3TXMw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JBvzcP2V7fT9KxFAMenHRd/t72qPP5IL5kzge2uok1T7t2nw3Wa+CWI5s6FYP42p2b1W9qZkv5Fno5gA7OAYuQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.31", "", { "os": "darwin", "cpu": "x64" }, "sha512-MDxfSloyrl/AzTIgUvEQm61MHSG753f8UzKdg+gZTzUHb7kWwpPfYrzFAVwN9AnURVUMKvTzoFBZ61UxOSIarw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-x7DY6VCkAky10z/2o4UkkuNW/nIvoX7uAh3dJOHWZCLbiKywSFvFk3QZVVcH5BMk4tOOophYTzika4s4HpaeMg=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.31", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+/F3lIsn7aHTqugO5hvdHjwILs/p92P+lAGCK9iBkEX20gTk9dOc6IUpC8iy0eNUJyCjYAilkWtAVIbS+S47Q=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBc1EdkVxsLBtqGjXM2BYpBJLa57ogcrSADSZbc5cQkPu0muSGzUwBbVnVZJUjWEfk6n5jcd4dDmLezVoQga0A=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.31", "", { "os": "linux", "cpu": "x64" }, "sha512-sjDrN4KIT305dycX5A50jNPCcf7nVLKGkJwY7g4x+eWuOItbRCfChr3CyniABDbUlJkPiB8/tvbM/7tID7mjqQ=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.33", "", { "os": "linux", "cpu": "x64" }, "sha512-3oVL5mrLlKLUc1lc4v7xS3BJ9N7PnnimbGwAvlnVpfaAygotAs1XkPcjsUe6ItMnSJyi0FWiDHUE2+GiDtM5Nw=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.31", "", { "os": "win32", "cpu": "arm64" }, "sha512-4xbr/a75YoskNj0c91RRvib5tV77WTZG4DQVgmSwi8osGIDGZnZjpx5nMYU25m9b7NSJW6+kGYzPy/FHwaZtjg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q68v7wssE+r0OG1KIGfi7m3fnu8KOK4ZNg9ML6EwE47VF9/bqgUe+6fPiXh5mmHzTwof7nAOdXCf052av5/upQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.31", "", { "os": "win32", "cpu": "x64" }, "sha512-LhyPfR5PuX6hY1LBteAUz5khO8hxV3rLnk2inGEDMffBUkrN2XW0+R635BIIFtq/tYFeTf0mzf+/DwvhiLcgbg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.33", "", { "os": "win32", "cpu": "x64" }, "sha512-PvuchmUnbMCUXXMzfle/WTzhNGIdJ6RGCCoclx3YVUyNUVuUicPf42OEV+td2m81/Hr3CgcLn98HYX1TLIzPrw=="], - "@opentui/react": ["@opentui/react@0.1.31", "", { "dependencies": { "@opentui/core": "0.1.31", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-VG+6PrhuKekHpzMSJlGFV76OiytP55RXMZLz3D4eq19/T6to1GTL97lYgZbsNgxwhl3uB9OY61pr2Jir6/CBkw=="], + "@opentui/react": ["@opentui/react@0.1.33", "", { "dependencies": { "@opentui/core": "0.1.33", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0" } }, "sha512-1zTXedsXnIBFs0euR2JtILuzCr9evQbxMUclx+t3CCGyAccOhoekGxO2los9aQOxVSLHZ+YR7aPgpTsPlGqmGg=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], @@ -4130,6 +4130,8 @@ "@codebuff/sdk/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@codebuff/sdk/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "@codebuff/web/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/type-utils": "8.46.2", "@typescript-eslint/utils": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w=="], "@codebuff/web/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], diff --git a/cli/package.json b/cli/package.json index 1588cce5f..d6b230a90 100644 --- a/cli/package.json +++ b/cli/package.json @@ -33,8 +33,8 @@ }, "dependencies": { "@codebuff/sdk": "workspace:*", - "@opentui/core": "0.1.31", - "@opentui/react": "0.1.31", + "@opentui/core": "0.1.33", + "@opentui/react": "0.1.33", "@tanstack/react-query": "^5.62.8", "commander": "^14.0.1", "immer": "^10.1.3", From ae768784476f69b4f346a483df3ed9c99ef0b45b Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 16:02:04 -0800 Subject: [PATCH 29/41] Clean up theme usage in components - Remove resolveThemeColor calls from components (handled in theme building) - Components now use theme properties directly with ?? fallbacks - Simplify AgentBranchItem and ToolCallItem props (remove showBorder, toggleEnabled) - Auto-resolve 'default' color values to actual colors during theme building - ThemeColor is always a string (never undefined or 'default') Components are now simpler and just consume ready-to-use theme values. --- cli/src/components/agent-branch-item.tsx | 76 ++++++++---------------- cli/src/components/message-block.tsx | 22 ++----- cli/src/components/tool-call-item.tsx | 46 ++++---------- cli/src/types/theme-system.ts | 1 + cli/src/utils/theme-config.ts | 38 +++++++++++- 5 files changed, 78 insertions(+), 105 deletions(-) diff --git a/cli/src/components/agent-branch-item.tsx b/cli/src/components/agent-branch-item.tsx index 80cd658b6..87da093e5 100644 --- a/cli/src/components/agent-branch-item.tsx +++ b/cli/src/components/agent-branch-item.tsx @@ -2,8 +2,7 @@ import { TextAttributes, type BorderCharacters } from '@opentui/core' import React, { type ReactNode } from 'react' import { RaisedPill } from './raised-pill' - -import type { ChatTheme } from '../utils/theme-system' +import { useTheme } from '../hooks/use-theme' interface AgentBranchItemProps { name: string @@ -18,10 +17,7 @@ interface AgentBranchItemProps { statusLabel?: string statusColor?: string statusIndicator?: string - theme: ChatTheme onToggle?: () => void - showBorder?: boolean - toggleEnabled?: boolean titleSuffix?: string } @@ -52,24 +48,10 @@ export const AgentBranchItem = ({ statusLabel, statusColor, statusIndicator = '●', - theme, onToggle, - showBorder = true, - toggleEnabled = true, titleSuffix, }: AgentBranchItemProps) => { - const resolveFg = ( - color?: string | null, - fallback?: string | null, - ): string | undefined => { - if (color && color !== 'default') return color - if (fallback && fallback !== 'default') return fallback - return undefined - } - const fallbackTextColor = - resolveFg(theme.agentContentText) ?? - resolveFg(theme.chromeText) ?? - '#d1d5e5' + const theme = useTheme() const baseTextAttributes = theme.messageTextAttributes ?? 0 const getAttributes = (extra: number = 0): number | undefined => { @@ -80,19 +62,12 @@ export const AgentBranchItem = ({ const isExpanded = !isCollapsed const toggleFrameColor = isExpanded ? theme.agentToggleExpandedBg - : theme.agentResponseCount ?? theme.agentToggleHeaderBg - const toggleIconColor = isStreaming - ? theme.statusAccent - : theme.chromeText ?? toggleFrameColor - const toggleLabelColor = theme.chromeText ?? toggleFrameColor - const toggleIndicator = toggleEnabled ? (isCollapsed ? '▸ ' : '▾ ') : '' + : theme.agentResponseCount + const toggleIconColor = isStreaming ? theme.statusAccent : theme.chromeText + const toggleIndicator = onToggle ? (isCollapsed ? '▸ ' : '▾ ') : '' const toggleLabel = `${branchChar}${toggleIndicator}` const collapseButtonFrame = theme.agentToggleExpandedBg const collapseButtonText = collapseButtonFrame - const toggleFrameFg = resolveFg(toggleFrameColor, fallbackTextColor) - const toggleIconFg = resolveFg(toggleIconColor, fallbackTextColor) - const toggleLabelFg = resolveFg(toggleLabelColor, fallbackTextColor) - const headerFg = resolveFg(theme.agentToggleHeaderText, fallbackTextColor) const statusText = statusLabel && statusLabel.length > 0 ? statusIndicator === '✓' @@ -149,7 +124,7 @@ export const AgentBranchItem = ({ if (isTextRenderable(value)) { return ( @@ -204,10 +179,10 @@ export const AgentBranchItem = ({ }} > - Prompt + Prompt @@ -244,27 +219,27 @@ export const AgentBranchItem = ({ style={{ flexDirection: 'row', alignItems: 'center', - paddingLeft: showBorder ? 1 : 0, - paddingRight: showBorder ? 1 : 0, + paddingLeft: 1, + paddingRight: 1, paddingTop: 0, paddingBottom: isCollapsed ? 0 : 1, width: '100%', }} - onMouseDown={toggleEnabled && onToggle ? onToggle : undefined} + onMouseDown={onToggle} > - + {toggleLabel} {name} {titleSuffix ? ( {` ${titleSuffix}`} @@ -292,10 +267,7 @@ export const AgentBranchItem = ({ }} > {isStreaming ? streamingPreview : finishedPreview} @@ -321,11 +293,11 @@ export const AgentBranchItem = ({ marginBottom: content ? 1 : 0, }} > - + Prompt @@ -333,7 +305,7 @@ export const AgentBranchItem = ({ {content && ( Response @@ -342,12 +314,12 @@ export const AgentBranchItem = ({ )} {renderExpandedContent(content)} - {toggleEnabled && onToggle && ( + {onToggle && ( value.replace(/[\r\n]+$/g, '') @@ -73,10 +72,7 @@ export const MessageBlock = ({ onToggleCollapsed, registerAgentRef, }: MessageBlockProps): ReactNode => { - const resolvedTextColor = - resolveThemeColor(textColor, theme.messageAiText) ?? - resolveThemeColor(theme.messageAiText, '#cbd5f5') ?? - '#cbd5f5' + const resolvedTextColor = textColor ?? theme.messageAiText // Get elapsed time from timer for streaming AI messages const elapsedSeconds = timer.elapsedSeconds @@ -239,7 +235,7 @@ export const MessageBlock = ({ ? null : ( onToggleCollapsed(toolBlock.toolCallId)} titleSuffix={toolRenderConfig.path} /> @@ -361,7 +356,6 @@ export const MessageBlock = ({ statusLabel={statusLabel ?? undefined} statusColor={statusColor} statusIndicator={statusIndicator} - theme={theme} onToggle={() => onToggleCollapsed(agentBlock.agentId)} /> @@ -446,7 +440,6 @@ export const MessageBlock = ({ branchChar="" streamingPreview="" finishedPreview={finishedPreview} - theme={theme} onToggle={() => onToggleCollapsed(agentListBlock.id)} /> @@ -488,16 +481,13 @@ export const MessageBlock = ({ typeof (nestedBlock as any).color === 'string' ? ((nestedBlock as any).color as string) : undefined - const nestedTextColor = resolveThemeColor( - explicitColor, - theme.agentText, - ) + const nestedTextColor = explicitColor ?? theme.agentText nodes.push( {renderedContent} diff --git a/cli/src/components/tool-call-item.tsx b/cli/src/components/tool-call-item.tsx index 646f6cde2..c756a55a3 100644 --- a/cli/src/components/tool-call-item.tsx +++ b/cli/src/components/tool-call-item.tsx @@ -1,8 +1,8 @@ import { TextAttributes } from '@opentui/core' import React, { type ReactNode } from 'react' -import type { ChatTheme } from '../utils/theme-system' -import { resolveThemeColor } from '../utils/theme-system' +import { useTheme } from '../hooks/use-theme' +import type { ChatTheme } from '../types/theme-system' interface ToolCallItemProps { name: string @@ -12,7 +12,6 @@ interface ToolCallItemProps { branchChar: string streamingPreview: string finishedPreview: string - theme: ChatTheme onToggle?: () => void titleSuffix?: string } @@ -54,7 +53,6 @@ const isTextRenderable = (value: ReactNode): boolean => { const renderExpandedContent = ( value: ReactNode, theme: ChatTheme, - fallbackTextColor: string, getAttributes: (extra?: number) => number | undefined, ): ReactNode => { if ( @@ -69,7 +67,7 @@ const renderExpandedContent = ( if (isTextRenderable(value)) { return ( @@ -119,23 +117,10 @@ export const ToolCallItem = ({ branchChar, streamingPreview, finishedPreview, - theme, onToggle, titleSuffix, }: ToolCallItemProps) => { - const resolveFg = ( - color?: string | null, - fallback?: string | null, - ): string | undefined => { - if (color && color !== 'default') return color - if (fallback && fallback !== 'default') return fallback - return undefined - } - - const fallbackTextColor = - resolveFg(theme.agentContentText) ?? - resolveFg(theme.chromeText) ?? - '#d1d5e5' + const theme = useTheme() const baseTextAttributes = theme.messageTextAttributes ?? 0 const getAttributes = (extra: number = 0): number | undefined => { @@ -144,11 +129,8 @@ export const ToolCallItem = ({ } const isExpanded = !isCollapsed - const toggleLabelColor = theme.chromeText ?? theme.agentToggleHeaderBg const toggleIndicator = onToggle ? (isCollapsed ? '▸ ' : '▾ ') : '' const toggleLabel = `${branchChar}${toggleIndicator}` - const toggleLabelFg = resolveFg(toggleLabelColor, fallbackTextColor) - const headerFg = resolveFg(theme.agentToggleHeaderText, fallbackTextColor) const collapsedPreviewText = isStreaming ? streamingPreview : finishedPreview const showCollapsedPreview = collapsedPreviewText.length > 0 @@ -179,20 +161,20 @@ export const ToolCallItem = ({ > {toggleLabel} {name} {titleSuffix ? ( {` ${titleSuffix}`} @@ -200,7 +182,7 @@ export const ToolCallItem = ({ ) : null} {isStreaming ? ( {' running'} @@ -220,10 +202,7 @@ export const ToolCallItem = ({ }} > {collapsedPreviewText} @@ -241,12 +220,7 @@ export const ToolCallItem = ({ paddingBottom: 0, }} > - {renderExpandedContent( - content, - theme, - fallbackTextColor ?? '#d1d5e5', - getAttributes, - )} + {renderExpandedContent(content, theme, getAttributes)} )} diff --git a/cli/src/types/theme-system.ts b/cli/src/types/theme-system.ts index 27ea2c322..46645df9b 100644 --- a/cli/src/types/theme-system.ts +++ b/cli/src/types/theme-system.ts @@ -2,6 +2,7 @@ export type ThemeName = 'dark' | 'light' export type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 +// ThemeColor is always a resolved color string (never 'default' or undefined) export type ThemeColor = string export interface MarkdownThemeOverrides { diff --git a/cli/src/utils/theme-config.ts b/cli/src/utils/theme-config.ts index fd5f7d1f2..04e6fbe74 100644 --- a/cli/src/utils/theme-config.ts +++ b/cli/src/utils/theme-config.ts @@ -288,16 +288,49 @@ export const applyVariantBackgrounds = ( } } +/** + * Resolve 'default' color values to fallback colors for ready-to-use theme + * Components should never see 'default' - it's resolved during theme building + * We use sensible fallbacks that work in both light and dark modes + */ +const resolveThemeColors = (theme: ChatTheme, mode: 'dark' | 'light'): void => { + const defaultFallback = mode === 'dark' ? '#ffffff' : '#000000' + + const resolve = (value: string, fallback: string = defaultFallback): string => { + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'default' || normalized.length === 0) { + return fallback + } + return value + } + return fallback + } + + // Resolve all ThemeColor properties to actual colors + theme.chromeText = resolve(theme.chromeText) + theme.messageAiText = resolve(theme.messageAiText) + theme.messageUserText = resolve(theme.messageUserText) + theme.inputFg = resolve(theme.inputFg) + theme.inputFocusedFg = resolve(theme.inputFocusedFg) + theme.inputPlaceholder = resolve(theme.inputPlaceholder, theme.statusSecondary) + theme.agentText = resolve(theme.agentText) + theme.agentContentText = resolve(theme.agentContentText) + theme.agentToggleHeaderText = resolve(theme.agentToggleHeaderText) + theme.agentToggleText = resolve(theme.agentToggleText) +} + /** * Build a complete theme by layering overrides * Applies variant backgrounds, config overrides, custom colors, and plugins + * All 'default' color values are resolved to undefined for ready-to-use theme * @param baseTheme - The base theme to start from * @param variant - Theme variant to apply * @param variantConfig - Configuration for the variant * @param mode - Current theme mode (dark or light) * @param customColors - Optional custom color overrides * @param plugins - Optional theme plugins to apply - * @returns Complete theme with all layers applied + * @returns Complete theme with all layers applied and colors resolved */ export const buildThemeWithVariant = ( baseTheme: ChatTheme, @@ -331,5 +364,8 @@ export const buildThemeWithVariant = ( } } + // Final step: Resolve all 'default' values to actual colors + resolveThemeColors(theme, mode) + return theme } From 3079589739e62ecf6da74e39192d086d831a163b Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 16:05:04 -0800 Subject: [PATCH 30/41] Remove resolveColor logic from remaining components - Remove resolveFg/resolveThemeColor from SuggestionMenu, MultilineInput, RaisedPill - Components use theme properties directly (e.g., fg={theme.inputFg}) - All color resolution happens during theme building, not in components - Cleaner, more maintainable component code --- cli/src/components/multiline-input.tsx | 30 +++++++++----------------- cli/src/components/raised-pill.tsx | 24 ++++++--------------- cli/src/components/suggestion-menu.tsx | 23 ++------------------ 3 files changed, 19 insertions(+), 58 deletions(-) diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index b807a993b..a86d9d9ea 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -574,34 +574,24 @@ export const MultilineInput = forwardRef< maxHeight, ]) - const resolveFg = ( - color?: string | null, - fallback?: string | null, - ): string | undefined => { - if (color && color !== 'default') return color - if (fallback && fallback !== 'default') return fallback - return undefined + const inputColor = isPlaceholder + ? theme.inputPlaceholder + : focused + ? theme.inputFocusedFg + : theme.inputFg + + const textStyle: Record = { + bg: 'transparent', + fg: inputColor, } - const resolvedInputColor = resolveFg( - isPlaceholder - ? theme.inputPlaceholder - : focused - ? theme.inputFocusedFg ?? theme.inputFg - : theme.inputFg, - ) - - const textStyle: Record = { bg: 'transparent' } - if (resolvedInputColor) { - textStyle.fg = resolvedInputColor - } if (isPlaceholder) { textStyle.attributes = TextAttributes.DIM } else if (textAttributes !== undefined && textAttributes !== 0) { textStyle.attributes = textAttributes } - const cursorFg = resolveFg(theme.cursor, theme.statusAccent) + const cursorFg = theme.cursor return ( { - const resolveFg = (color?: string): string | undefined => - color && color !== 'default' ? color : undefined - - const resolvedFrameColor = resolveFg(frameColor) - const resolvedTextColor = resolveFg(textColor) - const leftRightPadding = padding > 0 - ? [{ text: ' '.repeat(padding), fg: resolvedTextColor }] + ? [{ text: ' '.repeat(padding), fg: textColor }] : [] const normalizedSegments: Array<{ @@ -50,7 +44,7 @@ export const RaisedPill = ({ ...leftRightPadding, ...segments.map((segment) => ({ text: segment.text, - fg: resolveFg(segment.fg ?? textColor), + fg: segment.fg ?? textColor, attr: segment.attr, })), ...leftRightPadding, @@ -71,32 +65,28 @@ export const RaisedPill = ({ onMouseDown={onPress} > - {`╭${horizontal}╮`} + {`╭${horizontal}╮`} - + {normalizedSegments.map((segment, idx) => ( {segment.text} ))} - + - {`╰${horizontal}╯`} + {`╰${horizontal}╯`} ) diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index 95f8d3cde..6bf5b34d6 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -22,18 +22,6 @@ export const SuggestionMenu = ({ prefix = '/', }: SuggestionMenuProps) => { const theme = useTheme() - const resolveFg = ( - color?: string | null, - fallback?: string | null, - ): string | undefined => { - if (color && color !== 'default') return color - if (fallback && fallback !== 'default') return fallback - return undefined - } - const fallbackTextColor = - resolveFg(theme.agentContentText) ?? resolveFg(theme.chromeText) ?? '#d1d5e5' - const fallbackDescriptionColor = - resolveFg(theme.timestampUser) ?? fallbackTextColor if (items.length === 0) { return null @@ -88,11 +76,6 @@ export const SuggestionMenu = ({ const descriptionColor = isSelected ? theme.statusAccent : theme.timestampUser - const textFg = resolveFg(textColor, fallbackTextColor) - const descriptionFg = resolveFg( - descriptionColor, - fallbackDescriptionColor, - ) return ( {effectivePrefix} {item.label} {padding} - + {item.description} From a5910a11bb214fc3a59b40b5cd580deee47f1cc9 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 16:28:04 -0800 Subject: [PATCH 31/41] Complete theme system cleanup across all components - Add missing theme properties: linkColor, linkActiveColor, validationBorderColor, shimmerPrimaryColor, shimmerFallbackColor - Remove all resolveThemeColor/resolveFg calls from components - Fix import paths: use ../types/theme-system for type imports - Refactor MessageBlock, ToolItem to use useTheme() hook instead of theme prop - Update TerminalLink to use theme colors as defaults - Update ShimmerText to use theme shimmer colors - Replace hardcoded colors in chat.tsx with theme properties - All components now use clean, direct theme property access Theme system is now completely consistent - no resolution logic in components, all colors pre-resolved during theme building, single source of truth. --- cli/src/chat.tsx | 25 +++++---------------- cli/src/components/agent-mode-toggle.tsx | 2 +- cli/src/components/message-block.tsx | 4 ++-- cli/src/components/shimmer-text.tsx | 28 ++++++++++-------------- cli/src/components/terminal-link.tsx | 13 ++++++++--- cli/src/components/tool-item.tsx | 15 +++++-------- cli/src/components/tool-renderer.tsx | 5 ++--- cli/src/hooks/use-message-renderer.tsx | 2 -- cli/src/types/theme-system.ts | 5 +++++ cli/src/utils/theme-system.ts | 10 +++++++++ 10 files changed, 53 insertions(+), 56 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index ef09746ee..ca5f9e8af 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -48,7 +48,6 @@ import { handleSlashCommands } from './utils/slash-commands' import { chatThemes, createMarkdownPalette, - resolveThemeColor, } from './utils/theme-system' import { env } from '@codebuff/common/env' import { clientEnvVars } from '@codebuff/common/env-schema' @@ -209,12 +208,7 @@ export const App = ({ ? `Welcome back, ${userCredentials.name.trim()}!` : null - const baseTextColor = - resolvedThemeName === 'dark' - ? '#ffffff' - : theme.chromeText && theme.chromeText !== 'default' - ? theme.chromeText - : theme.agentResponseCount + const baseTextColor = theme.chromeText const homeDir = os.homedir() const repoRoot = path.dirname(loadedAgentsData.agentsDir) @@ -244,9 +238,6 @@ export const App = ({ }) } - const baseTextColorValue = - resolveThemeColor(baseTextColor, '#cbd5f5') ?? '#cbd5f5' - // Log all client environment variables (works with both dev and binary modes) const envVarsList = clientEnvVars .map((key) => { @@ -303,11 +294,10 @@ export const App = ({ type: 'html', render: () => ( - + Codebuff can read and write files in{' '} openFileAtPath(repoRoot)} @@ -324,11 +314,10 @@ export const App = ({ type: 'html', render: () => ( - + Codebuff can read and write files in{' '} openFileAtPath(repoRoot)} @@ -1134,10 +1123,8 @@ export const App = ({ return output } - const messageAiTextColor = - resolveThemeColor(theme.messageAiText, '#cbd5f5') ?? '#cbd5f5' - const statusSecondaryColor = - resolveThemeColor(theme.statusSecondary, '#94a3b8') ?? '#94a3b8' + const messageAiTextColor = theme.messageAiText + const statusSecondaryColor = theme.statusSecondary return ( {/* Header */} diff --git a/cli/src/components/agent-mode-toggle.tsx b/cli/src/components/agent-mode-toggle.tsx index 6836630ee..6fe4fe7d1 100644 --- a/cli/src/components/agent-mode-toggle.tsx +++ b/cli/src/components/agent-mode-toggle.tsx @@ -2,7 +2,7 @@ import { RaisedPill } from './raised-pill' import { useTheme } from '../hooks/use-theme' import type { AgentMode } from '../utils/constants' -import type { ChatTheme } from '../utils/theme-system' +import type { ChatTheme } from '../types/theme-system' const getModeConfig = (theme: ChatTheme) => ({ diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index b2bfdf376..a5346f264 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -6,6 +6,7 @@ import { pluralize } from '@codebuff/common/util/string' import { AgentBranchItem } from './agent-branch-item' import { ToolCallItem } from './tool-call-item' +import { useTheme } from '../hooks/use-theme' import { getToolDisplayInfo } from '../utils/codebuff-client' import { getToolRenderConfig } from './tool-renderer' import { @@ -37,7 +38,6 @@ interface MessageBlockProps { completionTime?: string credits?: number timer: ElapsedTimeTracker - theme: ChatTheme textColor?: ThemeColor timestampColor: string markdownOptions: { codeBlockWidth: number; palette: MarkdownPalette } @@ -61,7 +61,6 @@ export const MessageBlock = ({ completionTime, credits, timer, - theme, textColor, timestampColor, markdownOptions, @@ -72,6 +71,7 @@ export const MessageBlock = ({ onToggleCollapsed, registerAgentRef, }: MessageBlockProps): ReactNode => { + const theme = useTheme() const resolvedTextColor = textColor ?? theme.messageAiText // Get elapsed time from timer for streaming AI messages diff --git a/cli/src/components/shimmer-text.tsx b/cli/src/components/shimmer-text.tsx index dba8d182f..07d2690f4 100644 --- a/cli/src/components/shimmer-text.tsx +++ b/cli/src/components/shimmer-text.tsx @@ -1,18 +1,7 @@ import { TextAttributes } from '@opentui/core' import React, { useEffect, useMemo, useState } from 'react' -export const DEFAULT_SHIMMER_COLORS = [ - '#ff8c00', - '#ff9100', - '#ff9500', - '#ff9a00', - '#ffa500', - '#ffa000', - '#ff9500', - '#ff8c00', - '#ff8300', - '#ff7700', -] +import { useTheme } from '../hooks/use-theme' const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)) @@ -113,10 +102,12 @@ const rgbToHex = (r: number, g: number, b: number): string => { const generatePaletteFromPrimary = ( primaryColor: string, size: number, + fallbackColor: string, ): string[] => { const baseRgb = hexToRgb(primaryColor) if (!baseRgb) { - return DEFAULT_SHIMMER_COLORS + // If we can't parse the color, return a simple palette using the fallback + return Array.from({ length: size }, () => fallbackColor) } const { h, s, l } = rgbToHsl(baseRgb.r, baseRgb.g, baseRgb.b) @@ -148,6 +139,7 @@ export const ShimmerText = ({ colors?: string[] primaryColor?: string }) => { + const theme = useTheme() const [pulse, setPulse] = useState(0) const chars = text.split('') const numChars = chars.length @@ -163,7 +155,7 @@ export const ShimmerText = ({ const generateColors = (length: number, colorPalette: string[]): string[] => { if (length === 0) return [] if (colorPalette.length === 0) { - return Array.from({ length }, () => '#dbeafe') + return Array.from({ length }, () => theme.shimmerFallbackColor) } if (colorPalette.length === 1) { return Array.from({ length }, () => colorPalette[0]) @@ -186,10 +178,12 @@ export const ShimmerText = ({ } if (primaryColor) { const paletteSize = Math.max(8, Math.min(20, Math.ceil(numChars * 1.5))) - return generatePaletteFromPrimary(primaryColor, paletteSize) + return generatePaletteFromPrimary(primaryColor, paletteSize, theme.shimmerFallbackColor) } - return DEFAULT_SHIMMER_COLORS - }, [colors, primaryColor, numChars]) + // Use theme shimmer color as default + const paletteSize = Math.max(8, Math.min(20, Math.ceil(numChars * 1.5))) + return generatePaletteFromPrimary(theme.shimmerPrimaryColor, paletteSize, theme.shimmerFallbackColor) + }, [colors, primaryColor, numChars, theme.shimmerPrimaryColor, theme.shimmerFallbackColor]) const generateAttributes = (length: number): number[] => { const attributes: number[] = [] diff --git a/cli/src/components/terminal-link.tsx b/cli/src/components/terminal-link.tsx index 65d6a28d0..45dfb920b 100644 --- a/cli/src/components/terminal-link.tsx +++ b/cli/src/components/terminal-link.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useMemo, useState } from 'react' +import { useTheme } from '../hooks/use-theme' + type FormatLinesFn = (text: string, maxWidth?: number) => string[] export interface TerminalLinkProps { @@ -22,8 +24,8 @@ export const TerminalLink: React.FC = ({ text, maxWidth, formatLines = defaultFormatLines, - color = '#3b82f6', - activeColor = '#22c55e', + color, + activeColor, underlineOnHover = true, isActive = false, onActivate, @@ -31,6 +33,11 @@ export const TerminalLink: React.FC = ({ lineWrap = false, inline = false, }) => { + const theme = useTheme() + + // Use theme colors as defaults if not provided + const linkColor = color ?? theme.linkColor + const linkActiveColor = activeColor ?? theme.linkActiveColor const [isHovered, setIsHovered] = useState(false) const displayLines = useMemo(() => { @@ -41,7 +48,7 @@ export const TerminalLink: React.FC = ({ return formatted.filter((line) => line.trim().length > 0) }, [formatLines, maxWidth, text]) - const displayColor = isActive ? activeColor : color + const displayColor = isActive ? linkActiveColor : linkColor const shouldUnderline = underlineOnHover && isHovered const handleActivate = useCallback(() => { diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index ad232fea2..32eb97429 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -1,7 +1,8 @@ import { TextAttributes } from '@opentui/core' import React, { type ReactNode } from 'react' -import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' +import { useTheme } from '../hooks/use-theme' +import type { ChatTheme } from '../types/theme-system' export interface ToolBranchMeta { hasPrevious: boolean @@ -16,14 +17,13 @@ interface ToolItemProps { isStreaming: boolean streamingPreview: string finishedPreview: string - theme: ChatTheme branchMeta: ToolBranchMeta onToggle: () => void titleColor?: string } const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { - const contentFg = resolveThemeColor(theme.agentContentText) + const contentFg = theme.agentContentText const contentAttributes = theme.messageTextAttributes !== undefined && theme.messageTextAttributes !== 0 ? theme.messageTextAttributes @@ -85,19 +85,16 @@ export const ToolItem = ({ isStreaming, streamingPreview, finishedPreview, - theme, branchMeta, onToggle, titleColor: customTitleColor, }: ToolItemProps) => { + const theme = useTheme() + const branchColor = theme.agentResponseCount const branchAttributes = TextAttributes.DIM const titleColor = customTitleColor ?? theme.statusSecondary - const previewColor = - resolveThemeColor( - isStreaming ? theme.agentText : theme.agentResponseCount, - theme.agentResponseCount, - ) ?? theme.agentResponseCount + const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount const baseTextAttributes = theme.messageTextAttributes ?? 0 const connectorSymbol = branchMeta.hasNext ? '├' : '└' const continuationPrefix = branchMeta.hasNext ? '│ ' : ' ' diff --git a/cli/src/components/tool-renderer.tsx b/cli/src/components/tool-renderer.tsx index 9e9cb42b0..3204a7f66 100644 --- a/cli/src/components/tool-renderer.tsx +++ b/cli/src/components/tool-renderer.tsx @@ -3,7 +3,7 @@ import React from 'react' import stringWidth from 'string-width' import type { ContentBlock } from '../types/chat' -import { resolveThemeColor, type ChatTheme } from '../utils/theme-system' +import type { ChatTheme } from '../types/theme-system' type ToolBlock = Extract @@ -133,8 +133,7 @@ const getListDirectoryRender = ( return {} } - const summaryColor = - resolveThemeColor(theme.agentContentText) ?? theme.statusSecondary + const summaryColor = theme.agentContentText const baseAttributes = theme.messageTextAttributes ?? 0 const getAttributes = (extra: number = 0): number | undefined => { const combined = baseAttributes | extra diff --git a/cli/src/hooks/use-message-renderer.tsx b/cli/src/hooks/use-message-renderer.tsx index aef80b0f5..a620f6532 100644 --- a/cli/src/hooks/use-message-renderer.tsx +++ b/cli/src/hooks/use-message-renderer.tsx @@ -361,7 +361,6 @@ export const useMessageRenderer = ( completionTime={message.completionTime} credits={message.credits} timer={timer} - theme={theme} textColor={textColor} timestampColor={timestampColor} markdownOptions={markdownOptions} @@ -412,7 +411,6 @@ export const useMessageRenderer = ( completionTime={message.completionTime} credits={message.credits} timer={timer} - theme={theme} textColor={textColor} timestampColor={timestampColor} markdownOptions={markdownOptions} diff --git a/cli/src/types/theme-system.ts b/cli/src/types/theme-system.ts index 46645df9b..2d1acd66f 100644 --- a/cli/src/types/theme-system.ts +++ b/cli/src/types/theme-system.ts @@ -57,6 +57,11 @@ export interface ChatTheme { modeToggleMaxBg: string modeToggleMaxText: string logoColor: string + linkColor: string + linkActiveColor: string + validationBorderColor: string + shimmerPrimaryColor: string + shimmerFallbackColor: string markdown?: MarkdownThemeOverrides messageTextAttributes?: number } diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 47cc537e5..0220d3fcb 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -727,6 +727,11 @@ const DEFAULT_CHAT_THEMES: Record = { modeToggleMaxBg: '#dc2626', modeToggleMaxText: '#dc2626', logoColor: '#ffffff', + linkColor: '#38bdf8', + linkActiveColor: '#22c55e', + validationBorderColor: '#FFA500', + shimmerPrimaryColor: '#38bdf8', + shimmerFallbackColor: '#dbeafe', markdown: { codeBackground: '#1f2933', codeHeaderFg: '#5b647a', @@ -786,6 +791,11 @@ const DEFAULT_CHAT_THEMES: Record = { modeToggleMaxBg: '#dc2626', modeToggleMaxText: '#dc2626', logoColor: '#000000', + linkColor: '#3b82f6', + linkActiveColor: '#059669', + validationBorderColor: '#F59E0B', + shimmerPrimaryColor: '#3b82f6', + shimmerFallbackColor: '#94a3b8', markdown: { codeBackground: '#f3f4f6', codeHeaderFg: '#6b7280', From 021d5708fc5d280cb2666ce77db5eb65a4e5cfc8 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 16:34:30 -0800 Subject: [PATCH 32/41] fix(cli): remove duplicated welcome block in initial system message Drop the second HTML welcome block in buildBlocks so the intro text only renders once on startup. --- cli/src/chat.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index ca5f9e8af..72c0b68a2 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -310,24 +310,6 @@ export const App = ({ - blocks.push({ - type: 'html', - render: () => ( - - - Codebuff can read and write files in{' '} - openFileAtPath(repoRoot)} - /> - , and run terminal commands to help you build. - - - ), - }) - blocks.push({ type: 'agent-list', id: listId, From 75f9ce857a0a23df9ed31ec00ef1c2b7dbddc54d Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 16:43:49 -0800 Subject: [PATCH 33/41] Refactor to semantic color system with Tailwind-inspired structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 60+ specific color properties with semantic roles: - Core: primary, secondary, success, error, warning, info - Neutrals: foreground, background, muted, border, surface, surfaceHover - Context: aiLine, userLine, aiText, agentContent, etc. Changes: - Update ChatTheme interface with semantic color structure - Refactor dark and light theme definitions to use semantic colors - Update all 50+ component files to use new property names - statusAccent → primary - statusSecondary → secondary - messageAiText → aiText - chromeText → foreground - agentResponseCount → muted - logoColor → logo - linkColor → link - (and 20+ more mappings) - Remove color constants from login/constants.ts - Update LoginModal to use theme.success/error instead of constants - Fix background property mapping in theme-config.ts Benefits: - Much easier to create custom themes (15 semantic colors vs 60+ specific) - Cleaner component code (theme.primary vs theme.statusAccent) - Matches modern design system patterns (Tailwind, Chakra, MUI) - Single source of truth for color roles --- cli/src/chat.tsx | 18 +- .../message-block.completion.test.tsx | 8 +- .../message-block.streaming.test.tsx | 8 +- cli/src/components/agent-branch-item.tsx | 18 +- cli/src/components/agent-mode-toggle.tsx | 8 +- cli/src/components/login-modal.tsx | 33 ++- cli/src/components/message-block.tsx | 20 +- cli/src/components/separator.tsx | 2 +- cli/src/components/shimmer-text.tsx | 8 +- cli/src/components/status-indicator.tsx | 6 +- cli/src/components/suggestion-menu.tsx | 6 +- cli/src/components/terminal-link.tsx | 4 +- cli/src/components/tool-call-item.tsx | 8 +- cli/src/components/tool-item.tsx | 8 +- cli/src/components/tool-renderer.tsx | 2 +- cli/src/hooks/use-message-renderer.tsx | 30 +-- cli/src/login/constants.ts | 6 - cli/src/types/theme-system.ts | 180 ++++++++++++---- cli/src/utils/theme-config.ts | 27 ++- cli/src/utils/theme-system.ts | 192 ++++++++++-------- 20 files changed, 359 insertions(+), 233 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 72c0b68a2..0f8f141f3 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -208,7 +208,7 @@ export const App = ({ ? `Welcome back, ${userCredentials.name.trim()}!` : null - const baseTextColor = theme.chromeText + const baseTextColor = theme.foreground const homeDir = os.homedir() const repoRoot = path.dirname(loadedAgentsData.agentsDir) @@ -226,7 +226,7 @@ export const App = ({ { type: 'text', content: '\n\n' + logoBlock, - color: theme.logoColor, + color: theme.logo, }, ] @@ -1026,7 +1026,7 @@ export const App = ({ key="virtualization-notice" style={{ width: '100%', wrapMode: 'none' }} > - + Showing latest {virtualTopLevelMessages.length} of{' '} {topLevelMessages.length} messages. Scroll up to load more. @@ -1105,8 +1105,8 @@ export const App = ({ return output } - const messageAiTextColor = theme.messageAiText - const statusSecondaryColor = theme.statusSecondary + const messageAiTextColor = theme.aiText + const statusSecondaryColor = theme.secondary return ( {/* Header */} @@ -1237,11 +1237,11 @@ export const App = ({ {hasStatus && statusIndicatorNode} {hasStatus && (exitWarning || shouldShowQueuePreview) && ' '} {exitWarning && ( - {exitWarning} + {exitWarning} )} {exitWarning && shouldShowQueuePreview && ' '} {shouldShowQueuePreview && ( - + {' '} {formatQueuedPreview( queuedMessages, diff --git a/cli/src/components/__tests__/message-block.completion.test.tsx b/cli/src/components/__tests__/message-block.completion.test.tsx index adf6d3835..8118b2480 100644 --- a/cli/src/components/__tests__/message-block.completion.test.tsx +++ b/cli/src/components/__tests__/message-block.completion.test.tsx @@ -13,8 +13,8 @@ const basePalette = createMarkdownPalette(theme) const palette: MarkdownPalette = { ...basePalette, - inlineCodeFg: theme.messageAiText, - codeTextFg: theme.messageAiText, + inlineCodeFg: theme.aiText, + codeTextFg: theme.aiText, } const baseProps = { @@ -35,8 +35,8 @@ const baseProps = { startTime: null, }, theme, - textColor: theme.messageAiText, - timestampColor: theme.timestampAi, + textColor: theme.aiText, + timestampColor: theme.aiTimestamp, markdownOptions: { codeBlockWidth: 72, palette, diff --git a/cli/src/components/__tests__/message-block.streaming.test.tsx b/cli/src/components/__tests__/message-block.streaming.test.tsx index 5efee1b19..36d3e46ca 100644 --- a/cli/src/components/__tests__/message-block.streaming.test.tsx +++ b/cli/src/components/__tests__/message-block.streaming.test.tsx @@ -13,8 +13,8 @@ const basePalette = createMarkdownPalette(theme) const palette: MarkdownPalette = { ...basePalette, - inlineCodeFg: theme.messageAiText, - codeTextFg: theme.messageAiText, + inlineCodeFg: theme.aiText, + codeTextFg: theme.aiText, } const baseProps = { @@ -28,8 +28,8 @@ const baseProps = { completionTime: undefined, credits: undefined, theme, - textColor: theme.messageAiText, - timestampColor: theme.timestampAi, + textColor: theme.aiText, + timestampColor: theme.aiTimestamp, markdownOptions: { codeBlockWidth: 72, palette, diff --git a/cli/src/components/agent-branch-item.tsx b/cli/src/components/agent-branch-item.tsx index 87da093e5..bfa122a90 100644 --- a/cli/src/components/agent-branch-item.tsx +++ b/cli/src/components/agent-branch-item.tsx @@ -62,8 +62,8 @@ export const AgentBranchItem = ({ const isExpanded = !isCollapsed const toggleFrameColor = isExpanded ? theme.agentToggleExpandedBg - : theme.agentResponseCount - const toggleIconColor = isStreaming ? theme.statusAccent : theme.chromeText + : theme.muted + const toggleIconColor = isStreaming ? theme.primary : theme.foreground const toggleIndicator = onToggle ? (isCollapsed ? '▸ ' : '▾ ') : '' const toggleLabel = `${branchChar}${toggleIndicator}` const collapseButtonFrame = theme.agentToggleExpandedBg @@ -124,7 +124,7 @@ export const AgentBranchItem = ({ if (isTextRenderable(value)) { return ( @@ -207,7 +207,7 @@ export const AgentBranchItem = ({ > Prompt @@ -232,14 +232,14 @@ export const AgentBranchItem = ({ {toggleLabel} {name} {titleSuffix ? ( {` ${titleSuffix}`} @@ -247,7 +247,7 @@ export const AgentBranchItem = ({ ) : null} {statusText ? ( {` ${statusText}`} @@ -267,7 +267,7 @@ export const AgentBranchItem = ({ }} > {isStreaming ? streamingPreview : finishedPreview} @@ -297,7 +297,7 @@ export const AgentBranchItem = ({ Prompt diff --git a/cli/src/components/agent-mode-toggle.tsx b/cli/src/components/agent-mode-toggle.tsx index 6fe4fe7d1..453b1fcc2 100644 --- a/cli/src/components/agent-mode-toggle.tsx +++ b/cli/src/components/agent-mode-toggle.tsx @@ -7,13 +7,13 @@ import type { ChatTheme } from '../types/theme-system' const getModeConfig = (theme: ChatTheme) => ({ FAST: { - frameColor: theme.modeToggleFastBg, - textColor: theme.modeToggleFastText, + frameColor: theme.modeFastBg, + textColor: theme.modeFastText, label: 'FAST', }, MAX: { - frameColor: theme.modeToggleMaxBg, - textColor: theme.modeToggleMaxText, + frameColor: theme.modeMaxBg, + textColor: theme.modeMaxText, label: '💪 MAX', }, }) as const diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 2a0330135..3b55549f6 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -11,11 +11,6 @@ import { useLogo } from '../hooks/use-logo' import { useSheenAnimation } from '../hooks/use-sheen-animation' import { useTheme, VariantProvider } from '../hooks/use-theme' import { - LINK_COLOR_DEFAULT, - LINK_COLOR_CLICKED, - COPY_SUCCESS_COLOR, - COPY_ERROR_COLOR, - WARNING_COLOR, DEFAULT_TERMINAL_HEIGHT, MODAL_VERTICAL_MARGIN, MAX_MODAL_BASE_HEIGHT, @@ -271,7 +266,7 @@ const LoginModalContent = ({ // Use custom hook for sheen animation const { applySheenToChar } = useSheenAnimation({ - logoColor: theme.logoColor, + logoColor: theme.logo, terminalWidth: renderer?.width, sheenPosition, setSheenPosition, @@ -281,7 +276,7 @@ const LoginModalContent = ({ const { component: logoComponent } = useLogo({ availableWidth: contentMaxWidth, applySheenToChar, - textColor: theme.chromeText, + textColor: theme.foreground, }) // Calculate modal dimensions @@ -307,7 +302,7 @@ const LoginModalContent = ({ top={modalTop} border borderStyle="double" - borderColor={theme.statusAccent} + borderColor={theme.primary} style={{ width: modalWidth, height: modalHeight, @@ -323,14 +318,14 @@ const LoginModalContent = ({ style={{ width: '100%', padding: 1, - backgroundColor: '#ff0000', + backgroundColor: theme.error, borderStyle: 'single', - borderColor: WARNING_COLOR, + borderColor: theme.error, flexShrink: 0, }} > - + {isNarrow ? "⚠ Found API key but it's invalid. Please log in again." : '⚠ We found an API key but it appears to be invalid. Please log in again to continue.'} @@ -374,7 +369,7 @@ const LoginModalContent = ({ }} > - Loading... + Loading... )} @@ -395,7 +390,7 @@ const LoginModalContent = ({ {!isVerySmall && ( - + {isNarrow ? 'Please try again' : 'Please restart the CLI and try again'} @@ -417,7 +412,7 @@ const LoginModalContent = ({ }} > - + {isNarrow ? 'Press ENTER to login...' : 'Press ENTER to open your browser and finish logging in...'} @@ -439,7 +434,7 @@ const LoginModalContent = ({ }} > - + {isNarrow ? 'Click to copy:' : 'Click link to copy:'} @@ -454,8 +449,8 @@ const LoginModalContent = ({ text={loginUrl} maxWidth={maxUrlWidth} formatLines={formatLoginUrlLines} - color={hasClickedLink ? LINK_COLOR_CLICKED : LINK_COLOR_DEFAULT} - activeColor={LINK_COLOR_CLICKED} + color={hasClickedLink ? theme.linkActive : theme.link} + activeColor={theme.linkActive} underlineOnHover={true} isActive={justCopied} onActivate={handleActivateLoginUrl} @@ -479,8 +474,8 @@ const LoginModalContent = ({ {copyMessage} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index a5346f264..dd8d0bd6e 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -72,7 +72,7 @@ export const MessageBlock = ({ registerAgentRef, }: MessageBlockProps): ReactNode => { const theme = useTheme() - const resolvedTextColor = textColor ?? theme.messageAiText + const resolvedTextColor = textColor ?? theme.aiText // Get elapsed time from timer for streaming AI messages const elapsedSeconds = timer.elapsedSeconds @@ -140,8 +140,8 @@ export const MessageBlock = ({ codeBlockWidth: Math.max(10, availableWidth - 12 - indentationOffset), palette: { ...markdownPalette, - inlineCodeFg: theme.agentText, - codeTextFg: theme.agentText, + inlineCodeFg: theme.agentContent, + codeTextFg: theme.agentContent, }, } } @@ -235,7 +235,7 @@ export const MessageBlock = ({ ? null : ( { const identifier = formatIdentifier(agent) return ( - + {` • ${identifier}`} ) @@ -481,7 +481,7 @@ export const MessageBlock = ({ typeof (nestedBlock as any).color === 'string' ? ((nestedBlock as any).color as string) : undefined - const nestedTextColor = explicitColor ?? theme.agentText + const nestedTextColor = explicitColor ?? theme.agentContent nodes.push( {nestedBlock.render({ - textColor: theme.agentText, + textColor: theme.agentContent, theme, })} , @@ -694,7 +694,7 @@ export const MessageBlock = ({ attributes={TextAttributes.DIM} style={{ wrapMode: 'none', - fg: theme.statusSecondary, + fg: theme.secondary, marginTop: 0, marginBottom: 0, alignSelf: 'flex-start', @@ -709,7 +709,7 @@ export const MessageBlock = ({ attributes={TextAttributes.DIM} style={{ wrapMode: 'none', - fg: theme.statusSecondary, + fg: theme.secondary, marginTop: 0, marginBottom: 0, alignSelf: 'flex-start', diff --git a/cli/src/components/separator.tsx b/cli/src/components/separator.tsx index fa7fdef06..014d00435 100644 --- a/cli/src/components/separator.tsx +++ b/cli/src/components/separator.tsx @@ -12,7 +12,7 @@ export const Separator = ({ width }: SeparatorProps) => { return ( ) } diff --git a/cli/src/components/shimmer-text.tsx b/cli/src/components/shimmer-text.tsx index 07d2690f4..fc15aaaa3 100644 --- a/cli/src/components/shimmer-text.tsx +++ b/cli/src/components/shimmer-text.tsx @@ -155,7 +155,7 @@ export const ShimmerText = ({ const generateColors = (length: number, colorPalette: string[]): string[] => { if (length === 0) return [] if (colorPalette.length === 0) { - return Array.from({ length }, () => theme.shimmerFallbackColor) + return Array.from({ length }, () => theme.muted) } if (colorPalette.length === 1) { return Array.from({ length }, () => colorPalette[0]) @@ -178,12 +178,12 @@ export const ShimmerText = ({ } if (primaryColor) { const paletteSize = Math.max(8, Math.min(20, Math.ceil(numChars * 1.5))) - return generatePaletteFromPrimary(primaryColor, paletteSize, theme.shimmerFallbackColor) + return generatePaletteFromPrimary(primaryColor, paletteSize, theme.muted) } // Use theme shimmer color as default const paletteSize = Math.max(8, Math.min(20, Math.ceil(numChars * 1.5))) - return generatePaletteFromPrimary(theme.shimmerPrimaryColor, paletteSize, theme.shimmerFallbackColor) - }, [colors, primaryColor, numChars, theme.shimmerPrimaryColor, theme.shimmerFallbackColor]) + return generatePaletteFromPrimary(theme.shimmer, paletteSize, theme.muted) + }, [colors, primaryColor, numChars, theme.shimmer, theme.muted]) const generateAttributes = (length: number): number[] => { const attributes: number[] = [] diff --git a/cli/src/components/status-indicator.tsx b/cli/src/components/status-indicator.tsx index 41a93bfcc..35e19c261 100644 --- a/cli/src/components/status-indicator.tsx +++ b/cli/src/components/status-indicator.tsx @@ -50,7 +50,7 @@ export const StatusIndicator = ({ const elapsedSeconds = timer.elapsedSeconds if (clipboardMessage) { - return {clipboardMessage} + return {clipboardMessage} } const hasStatus = isConnected === false || isActive @@ -66,7 +66,7 @@ export const StatusIndicator = ({ if (isActive) { // If we have elapsed time > 0, show it if (elapsedSeconds > 0) { - return {elapsedSeconds}s + return {elapsedSeconds}s } // Otherwise show thinking... @@ -74,7 +74,7 @@ export const StatusIndicator = ({ ) } diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index 6bf5b34d6..c89036a6c 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -72,10 +72,10 @@ export const SuggestionMenu = ({ const labelLength = effectivePrefix.length + item.label.length const paddingLength = Math.max(maxLabelLength - labelLength + 2, 2) const padding = ' '.repeat(paddingLength) - const textColor = isSelected ? theme.statusAccent : theme.inputFg + const textColor = isSelected ? theme.primary : theme.inputFg const descriptionColor = isSelected - ? theme.statusAccent - : theme.timestampUser + ? theme.primary + : theme.userTimestamp return ( = ({ const theme = useTheme() // Use theme colors as defaults if not provided - const linkColor = color ?? theme.linkColor - const linkActiveColor = activeColor ?? theme.linkActiveColor + const linkColor = color ?? theme.link + const linkActiveColor = activeColor ?? theme.linkActive const [isHovered, setIsHovered] = useState(false) const displayLines = useMemo(() => { diff --git a/cli/src/components/tool-call-item.tsx b/cli/src/components/tool-call-item.tsx index c756a55a3..fdac15216 100644 --- a/cli/src/components/tool-call-item.tsx +++ b/cli/src/components/tool-call-item.tsx @@ -67,7 +67,7 @@ const renderExpandedContent = ( if (isTextRenderable(value)) { return ( @@ -161,7 +161,7 @@ export const ToolCallItem = ({ > {toggleLabel} @@ -182,7 +182,7 @@ export const ToolCallItem = ({ ) : null} {isStreaming ? ( {' running'} @@ -202,7 +202,7 @@ export const ToolCallItem = ({ }} > {collapsedPreviewText} diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index 32eb97429..2940066ca 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -23,7 +23,7 @@ interface ToolItemProps { } const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { - const contentFg = theme.agentContentText + const contentFg = theme.agentContent const contentAttributes = theme.messageTextAttributes !== undefined && theme.messageTextAttributes !== 0 ? theme.messageTextAttributes @@ -91,10 +91,10 @@ export const ToolItem = ({ }: ToolItemProps) => { const theme = useTheme() - const branchColor = theme.agentResponseCount + const branchColor = theme.muted const branchAttributes = TextAttributes.DIM - const titleColor = customTitleColor ?? theme.statusSecondary - const previewColor = isStreaming ? theme.agentText : theme.agentResponseCount + const titleColor = customTitleColor ?? theme.secondary + const previewColor = isStreaming ? theme.agentContent : theme.muted const baseTextAttributes = theme.messageTextAttributes ?? 0 const connectorSymbol = branchMeta.hasNext ? '├' : '└' const continuationPrefix = branchMeta.hasNext ? '│ ' : ' ' diff --git a/cli/src/components/tool-renderer.tsx b/cli/src/components/tool-renderer.tsx index 3204a7f66..350ddcb67 100644 --- a/cli/src/components/tool-renderer.tsx +++ b/cli/src/components/tool-renderer.tsx @@ -133,7 +133,7 @@ const getListDirectoryRender = ( return {} } - const summaryColor = theme.agentContentText + const summaryColor = theme.agentContent const baseAttributes = theme.messageTextAttributes ?? 0 const getAttributes = (extra: number = 0): number | undefined => { const combined = baseAttributes | extra diff --git a/cli/src/hooks/use-message-renderer.tsx b/cli/src/hooks/use-message-renderer.tsx index a620f6532..de5f293cb 100644 --- a/cli/src/hooks/use-message-renderer.tsx +++ b/cli/src/hooks/use-message-renderer.tsx @@ -88,8 +88,8 @@ export const useMessageRenderer = ( const agentCodeBlockWidth = Math.max(10, availableWidth - 12) const agentPalette: MarkdownPalette = { ...markdownPalette, - inlineCodeFg: theme.agentText, - codeTextFg: theme.agentText, + inlineCodeFg: theme.agentContent, + codeTextFg: theme.agentContent, } const agentMarkdownOptions = { codeBlockWidth: agentCodeBlockWidth, @@ -176,7 +176,7 @@ export const useMessageRenderer = ( flexDirection: 'row', alignSelf: 'flex-start', backgroundColor: isCollapsed - ? theme.agentResponseCount + ? theme.muted : theme.agentPrefix, paddingLeft: 1, paddingRight: 1, @@ -184,11 +184,11 @@ export const useMessageRenderer = ( onMouseDown={handleTitleClick} > - + {isCollapsed ? '▸ ' : '▾ '} {agentInfo.agentName} @@ -201,7 +201,7 @@ export const useMessageRenderer = ( > {isStreaming && isCollapsed && streamingPreview && ( {streamingPreview} @@ -209,7 +209,7 @@ export const useMessageRenderer = ( )} {!isStreaming && isCollapsed && finishedPreview && ( {finishedPreview} @@ -218,7 +218,7 @@ export const useMessageRenderer = ( {!isCollapsed && ( {displayContent} @@ -273,15 +273,15 @@ export const useMessageRenderer = ( const isError = message.variant === 'error' const lineColor = isError ? 'red' : isAi ? theme.aiLine : theme.userLine const textColor = isError - ? theme.messageAiText + ? theme.aiText : isAi - ? theme.messageAiText - : theme.messageUserText + ? theme.aiText + : theme.userText const timestampColor = isError ? 'red' : isAi - ? theme.timestampAi - : theme.timestampUser + ? theme.aiTimestamp + : theme.userTimestamp const estimatedMessageWidth = availableWidth const codeBlockWidth = Math.max(10, estimatedMessageWidth - 8) const paletteForMessage: MarkdownPalette = { @@ -337,7 +337,7 @@ export const useMessageRenderer = ( /> ') */ agentPrefix: string + + /** Agent name color */ agentName: string - agentText: ThemeColor - agentCheckmark: string - agentResponseCount: string - agentFocusedBg: string - agentContentText: ThemeColor + + /** Agent content text color */ + agentContent: ThemeColor + + /** Agent toggle header background */ agentToggleHeaderBg: string + + /** Agent toggle header text */ agentToggleHeaderText: ThemeColor - agentToggleText: ThemeColor + + /** Agent toggle expanded background */ agentToggleExpandedBg: string + + /** Agent focused background */ + agentFocusedBg: string + + /** Agent content background */ agentContentBg: string - modeToggleFastBg: string - modeToggleFastText: string - modeToggleMaxBg: string - modeToggleMaxText: string - logoColor: string - linkColor: string - linkActiveColor: string - validationBorderColor: string - shimmerPrimaryColor: string - shimmerFallbackColor: string + + // Input specific + /** Input background */ + inputBg: string + + /** Input text color */ + inputFg: ThemeColor + + /** Focused input background */ + inputFocusedBg: string + + /** Focused input text color */ + inputFocusedFg: ThemeColor + + /** Input placeholder text color */ + inputPlaceholder: ThemeColor + + /** Cursor color */ + cursor: string + + // Mode toggles + /** Fast mode toggle background */ + modeFastBg: string + + /** Fast mode toggle text */ + modeFastText: string + + /** Max mode toggle background */ + modeMaxBg: string + + /** Max mode toggle text */ + modeMaxText: string + + // Misc + /** Logo/branding color */ + logo: string + + /** Link color */ + link: string + + /** Active/clicked link color */ + linkActive: string + + /** Shimmer animation color */ + shimmer: string + + /** Accent background (for highlights, selections) */ + accentBg: string + + /** Accent text color */ + accentText: string + + // ============================================================================ + // MARKDOWN + // ============================================================================ + + /** Markdown-specific styling */ markdown?: MarkdownThemeOverrides + + /** Text attributes (bold, dim, etc.) */ messageTextAttributes?: number } diff --git a/cli/src/utils/theme-config.ts b/cli/src/utils/theme-config.ts index 04e6fbe74..2920ef30f 100644 --- a/cli/src/utils/theme-config.ts +++ b/cli/src/utils/theme-config.ts @@ -31,17 +31,17 @@ export type BackgroundColor = 'auto' | 'transparent' | string export interface ThemeVariantBackgrounds { /** Main background color (replaces theme.background) */ main?: BackgroundColor - /** Chrome background color (replaces theme.chromeBg) */ + /** Chrome background color (replaces theme.surface) */ chrome?: BackgroundColor - /** Panel background color (replaces theme.panelBg) */ + /** Panel background color (replaces theme.surface) */ panel?: BackgroundColor - /** Message background color (replaces theme.messageBg) */ + /** Message background color (replaces theme.background) */ message?: BackgroundColor /** Input background color (replaces theme.inputBg) */ input?: BackgroundColor /** Focused input background color (replaces theme.inputFocusedBg) */ inputFocused?: BackgroundColor - /** Agent content background color (replaces theme.agentContentBg) */ + /** Agent content background color (replaces theme.background) */ agent?: BackgroundColor /** Accent background color (replaces theme.accentBg) */ accent?: BackgroundColor @@ -240,16 +240,14 @@ const BACKGROUND_PROPERTY_MAPPING: Array< [keyof ChatTheme, keyof ThemeVariantBackgrounds] > = [ ['background', 'main'], - ['chromeBg', 'chrome'], - ['panelBg', 'panel'], - ['messageBg', 'message'], + ['surface', 'chrome'], // Chrome and panel both map to surface now ['inputBg', 'input'], ['inputFocusedBg', 'inputFocused'], - ['agentContentBg', 'agent'], ['accentBg', 'accent'], ['agentFocusedBg', 'agentFocused'], ['agentToggleHeaderBg', 'agentToggleHeader'], ['agentToggleExpandedBg', 'agentToggleExpanded'], + ['agentContentBg', 'agent'], ] /** @@ -308,16 +306,15 @@ const resolveThemeColors = (theme: ChatTheme, mode: 'dark' | 'light'): void => { } // Resolve all ThemeColor properties to actual colors - theme.chromeText = resolve(theme.chromeText) - theme.messageAiText = resolve(theme.messageAiText) - theme.messageUserText = resolve(theme.messageUserText) + theme.foreground = resolve(theme.foreground) + theme.muted = resolve(theme.muted) + theme.aiText = resolve(theme.aiText) + theme.userText = resolve(theme.userText) + theme.agentContent = resolve(theme.agentContent) theme.inputFg = resolve(theme.inputFg) theme.inputFocusedFg = resolve(theme.inputFocusedFg) - theme.inputPlaceholder = resolve(theme.inputPlaceholder, theme.statusSecondary) - theme.agentText = resolve(theme.agentText) - theme.agentContentText = resolve(theme.agentContentText) + theme.inputPlaceholder = resolve(theme.inputPlaceholder, theme.secondary) theme.agentToggleHeaderText = resolve(theme.agentToggleHeaderText) - theme.agentToggleText = resolve(theme.agentToggleText) } /** diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 0220d3fcb..12434557e 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -689,49 +689,63 @@ export const detectSystemTheme = (): ThemeName => { const DEFAULT_CHAT_THEMES: Record = { dark: { + // Core semantic colors + primary: '#facc15', + secondary: '#a3aed0', + success: '#22c55e', + error: '#ef4444', + warning: '#FFA500', + info: '#38bdf8', + + // Neutral scale + foreground: '#f1f5f9', background: '#000000', - chromeBg: '#000000', - chromeText: '#9ca3af', - accentBg: '#facc15', - accentText: '#1c1917', - panelBg: '#000000', + muted: '#9ca3af', + border: '#334155', + surface: '#000000', + surfaceHover: '#334155', + + // AI/User context aiLine: '#34d399', userLine: '#38bdf8', - timestampAi: '#4ade80', - timestampUser: '#60a5fa', - messageAiText: '#f1f5f9', - messageUserText: '#dbeafe', - messageBg: '#000000', - statusAccent: '#facc15', - statusSecondary: '#a3aed0', + aiText: '#f1f5f9', + userText: '#dbeafe', + aiTimestamp: '#4ade80', + userTimestamp: '#60a5fa', + + // Agent context + agentPrefix: '#22c55e', + agentName: '#4ade80', + agentContent: '#ffffff', + agentToggleHeaderBg: '#f97316', + agentToggleHeaderText: '#ffffff', + agentToggleExpandedBg: '#1d4ed8', + agentFocusedBg: '#334155', + agentContentBg: '#000000', + + // Input inputBg: '#000000', inputFg: '#f5f5f5', inputFocusedBg: '#000000', inputFocusedFg: '#ffffff', inputPlaceholder: '#a3a3a3', cursor: '#22c55e', - agentPrefix: '#22c55e', - agentName: '#4ade80', - agentText: '#d1d5db', - agentCheckmark: '#22c55e', - agentResponseCount: '#9ca3af', - agentFocusedBg: '#334155', - agentContentText: '#ffffff', - agentToggleHeaderBg: '#f97316', - agentToggleHeaderText: '#ffffff', - agentToggleText: '#ffffff', - agentContentBg: '#000000', - agentToggleExpandedBg: '#1d4ed8', - modeToggleFastBg: '#f97316', - modeToggleFastText: '#f97316', - modeToggleMaxBg: '#dc2626', - modeToggleMaxText: '#dc2626', - logoColor: '#ffffff', - linkColor: '#38bdf8', - linkActiveColor: '#22c55e', - validationBorderColor: '#FFA500', - shimmerPrimaryColor: '#38bdf8', - shimmerFallbackColor: '#dbeafe', + + // Mode toggles + modeFastBg: '#f97316', + modeFastText: '#f97316', + modeMaxBg: '#dc2626', + modeMaxText: '#dc2626', + + // Misc + logo: '#ffffff', + link: '#38bdf8', + linkActive: '#22c55e', + shimmer: '#38bdf8', + accentBg: '#facc15', + accentText: '#1c1917', + + // Markdown markdown: { codeBackground: '#1f2933', codeHeaderFg: '#5b647a', @@ -753,49 +767,63 @@ const DEFAULT_CHAT_THEMES: Record = { }, }, light: { + // Core semantic colors + primary: '#f59e0b', + secondary: '#6b7280', + success: '#059669', + error: '#ef4444', + warning: '#F59E0B', + info: '#3b82f6', + + // Neutral scale + foreground: '#111827', background: '#ffffff', - chromeBg: '#f3f4f6', - chromeText: '#374151', - accentBg: '#f59e0b', - accentText: '#111827', - panelBg: '#ffffff', + muted: '#6b7280', + border: '#d1d5db', + surface: '#f3f4f6', + surfaceHover: '#e5e7eb', + + // AI/User context aiLine: '#059669', userLine: '#3b82f6', - timestampAi: '#047857', - timestampUser: '#2563eb', - messageAiText: '#111827', - messageUserText: '#1f2937', - messageBg: '#ffffff', - statusAccent: '#f59e0b', - statusSecondary: '#6b7280', + aiText: '#111827', + userText: '#1f2937', + aiTimestamp: '#047857', + userTimestamp: '#2563eb', + + // Agent context + agentPrefix: '#059669', + agentName: '#047857', + agentContent: '#111827', + agentToggleHeaderBg: '#ea580c', + agentToggleHeaderText: '#ffffff', + agentToggleExpandedBg: '#1d4ed8', + agentFocusedBg: '#f3f4f6', + agentContentBg: '#ffffff', + + // Input inputBg: '#f9fafb', inputFg: '#111827', inputFocusedBg: '#ffffff', inputFocusedFg: '#000000', inputPlaceholder: '#9ca3af', cursor: '#3b82f6', - agentPrefix: '#059669', - agentName: '#047857', - agentText: '#1f2937', - agentCheckmark: '#059669', - agentResponseCount: '#6b7280', - agentFocusedBg: '#f3f4f6', - agentContentText: '#111827', - agentToggleHeaderBg: '#ea580c', - agentToggleHeaderText: '#ffffff', - agentToggleText: '#ffffff', - agentContentBg: '#ffffff', - agentToggleExpandedBg: '#1d4ed8', - modeToggleFastBg: '#f97316', - modeToggleFastText: '#f97316', - modeToggleMaxBg: '#dc2626', - modeToggleMaxText: '#dc2626', - logoColor: '#000000', - linkColor: '#3b82f6', - linkActiveColor: '#059669', - validationBorderColor: '#F59E0B', - shimmerPrimaryColor: '#3b82f6', - shimmerFallbackColor: '#94a3b8', + + // Mode toggles + modeFastBg: '#f97316', + modeFastText: '#f97316', + modeMaxBg: '#dc2626', + modeMaxText: '#dc2626', + + // Misc + logo: '#000000', + link: '#3b82f6', + linkActive: '#059669', + shimmer: '#3b82f6', + accentBg: '#f59e0b', + accentText: '#111827', + + // Markdown markdown: { codeBackground: '#f3f4f6', codeHeaderFg: '#6b7280', @@ -828,30 +856,30 @@ export const chatThemes = (() => { export const createMarkdownPalette = (theme: ChatTheme): MarkdownPalette => { const headingDefaults: Record = { - 1: theme.statusAccent, - 2: theme.statusAccent, - 3: theme.statusAccent, - 4: theme.statusAccent, - 5: theme.statusAccent, - 6: theme.statusAccent, + 1: theme.primary, + 2: theme.primary, + 3: theme.primary, + 4: theme.primary, + 5: theme.primary, + 6: theme.primary, } const overrides = theme.markdown?.headingFg ?? {} return { - inlineCodeFg: theme.markdown?.inlineCodeFg ?? theme.messageAiText, - codeBackground: theme.markdown?.codeBackground ?? theme.messageBg, - codeHeaderFg: theme.markdown?.codeHeaderFg ?? theme.statusSecondary, + inlineCodeFg: theme.markdown?.inlineCodeFg ?? theme.aiText, + codeBackground: theme.markdown?.codeBackground ?? theme.background, + codeHeaderFg: theme.markdown?.codeHeaderFg ?? theme.secondary, headingFg: { ...headingDefaults, ...overrides, }, - listBulletFg: theme.markdown?.listBulletFg ?? theme.statusSecondary, + listBulletFg: theme.markdown?.listBulletFg ?? theme.secondary, blockquoteBorderFg: - theme.markdown?.blockquoteBorderFg ?? theme.statusSecondary, - blockquoteTextFg: theme.markdown?.blockquoteTextFg ?? theme.messageAiText, - dividerFg: theme.markdown?.dividerFg ?? theme.statusSecondary, - codeTextFg: theme.markdown?.codeTextFg ?? theme.messageAiText, + theme.markdown?.blockquoteBorderFg ?? theme.secondary, + blockquoteTextFg: theme.markdown?.blockquoteTextFg ?? theme.aiText, + dividerFg: theme.markdown?.dividerFg ?? theme.secondary, + codeTextFg: theme.markdown?.codeTextFg ?? theme.aiText, codeMonochrome: theme.markdown?.codeMonochrome ?? true, } } From 89d75149d3437a2f661f12147492e030109052cd Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 16:51:42 -0800 Subject: [PATCH 34/41] Fix test files to use ThemeProvider after semantic refactor - Wrap MessageBlock tests in ThemeProvider - Remove theme prop from test fixtures (now uses useTheme hook) - Update property names to match semantic structure (aiText, aiTimestamp) All 50 unit tests passing. --- .../message-block.completion.test.tsx | 30 +++++++++++-------- .../message-block.streaming.test.tsx | 26 +++++++++------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/cli/src/components/__tests__/message-block.completion.test.tsx b/cli/src/components/__tests__/message-block.completion.test.tsx index 8118b2480..e37b9ad32 100644 --- a/cli/src/components/__tests__/message-block.completion.test.tsx +++ b/cli/src/components/__tests__/message-block.completion.test.tsx @@ -4,6 +4,7 @@ import { describe, test, expect } from 'bun:test' import { renderToStaticMarkup } from 'react-dom/server' import { MessageBlock } from '../message-block' +import { ThemeProvider } from '../../hooks/use-theme' import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' import type { MarkdownPalette } from '../../utils/markdown-renderer' @@ -34,7 +35,6 @@ const baseProps = { elapsedSeconds: 0, startTime: null, }, - theme, textColor: theme.aiText, timestampColor: theme.aiTimestamp, markdownOptions: { @@ -52,12 +52,14 @@ const baseProps = { describe('MessageBlock completion time', () => { test('renders completion time and credits when complete', () => { const markup = renderToStaticMarkup( - , + + + , ) expect(markup).toContain('7s') @@ -66,12 +68,14 @@ describe('MessageBlock completion time', () => { test('omits completion line when not complete', () => { const markup = renderToStaticMarkup( - , + + + , ) expect(markup).not.toContain('7s') diff --git a/cli/src/components/__tests__/message-block.streaming.test.tsx b/cli/src/components/__tests__/message-block.streaming.test.tsx index 36d3e46ca..ec5db67e1 100644 --- a/cli/src/components/__tests__/message-block.streaming.test.tsx +++ b/cli/src/components/__tests__/message-block.streaming.test.tsx @@ -4,6 +4,7 @@ import { describe, test, expect } from 'bun:test' import { renderToStaticMarkup } from 'react-dom/server' import { MessageBlock } from '../message-block' +import { ThemeProvider } from '../../hooks/use-theme' import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' import type { MarkdownPalette } from '../../utils/markdown-renderer' @@ -27,7 +28,6 @@ const baseProps = { timestamp: '12:00', completionTime: undefined, credits: undefined, - theme, textColor: theme.aiText, timestampColor: theme.aiTimestamp, markdownOptions: { @@ -52,11 +52,13 @@ const createTimer = (elapsedSeconds: number) => ({ describe('MessageBlock streaming indicator', () => { test('shows elapsed seconds while streaming', () => { const markup = renderToStaticMarkup( - , + + + , ) expect(markup).toContain('4s') @@ -64,11 +66,13 @@ describe('MessageBlock streaming indicator', () => { test('hides elapsed seconds when timer has not advanced', () => { const markup = renderToStaticMarkup( - , + + + , ) expect(markup).not.toContain('0s') From fcbb766b3fa3ef122ec8d94f8dc08a2e670b9009 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 16:57:37 -0800 Subject: [PATCH 35/41] Remove variant system - simplify theme architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed unnecessary theme variant system (transparent/modal/embedded): - Variants were solving a non-existent problem (themes have solid backgrounds now) - Removed ThemeVariant type and all variant-related code - Removed VariantProvider component - Simplified theme-config.ts (300+ lines → 140 lines) - Simplified use-theme.tsx (180+ lines → 110 lines) - LoginModal now directly uses useTheme() without wrapper Theme system is now much simpler: - useTheme() hook returns the current theme - ThemeProvider wraps the app - Plugin system remains for future extensibility - All customization via themeConfig.customColors or plugins All typechecks pass, all 50 unit tests pass. --- cli/src/components/login-modal.tsx | 19 +-- cli/src/hooks/use-theme.tsx | 121 +++----------- cli/src/utils/theme-config.ts | 245 ++--------------------------- 3 files changed, 35 insertions(+), 350 deletions(-) diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 3b55549f6..834224f54 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -9,7 +9,7 @@ import { useLoginKeyboardHandlers } from '../hooks/use-login-keyboard-handlers' import { useLoginPolling } from '../hooks/use-login-polling' import { useLogo } from '../hooks/use-logo' import { useSheenAnimation } from '../hooks/use-sheen-animation' -import { useTheme, VariantProvider } from '../hooks/use-theme' +import { useTheme } from '../hooks/use-theme' import { DEFAULT_TERMINAL_HEIGHT, MODAL_VERTICAL_MARGIN, @@ -26,7 +26,6 @@ import { useLoginStore } from '../state/login-store' import { copyTextToClipboard } from '../utils/clipboard' import { logger } from '../utils/logger' -import type { ChatTheme } from '../types/theme-system' import type { User } from '../utils/auth' interface LoginModalProps { @@ -37,24 +36,8 @@ interface LoginModalProps { export const LoginModal = ({ onLoginSuccess, hasInvalidCredentials = false, -}: LoginModalProps) => { - return ( - - - - ) -} - -const LoginModalContent = ({ - onLoginSuccess, - hasInvalidCredentials, }: LoginModalProps) => { const renderer = useRenderer() - - // Use theme from context (will be modal variant due to VariantProvider) const theme = useTheme() // Use zustand store for all state diff --git a/cli/src/hooks/use-theme.tsx b/cli/src/hooks/use-theme.tsx index d2011b0b2..248e67dd0 100644 --- a/cli/src/hooks/use-theme.tsx +++ b/cli/src/hooks/use-theme.tsx @@ -1,37 +1,28 @@ /** * Theme Hooks and Context * - * Provides hook-based API for accessing themes with variant support + * Provides hook-based API for accessing themes */ import React, { createContext, useContext, useState, - useEffect, useMemo, - useCallback, } from 'react' import { chatThemes, cloneChatTheme, detectSystemTheme } from '../utils/theme-system' import type { ChatTheme } from '../types/theme-system' -import type { ThemeVariant } from '../utils/theme-config' -import { - getVariantConfig, - themeConfig, - buildThemeWithVariant, -} from '../utils/theme-config' +import { themeConfig, buildTheme } from '../utils/theme-config' /** * Theme context value */ interface ThemeContextValue { - /** Base theme from auto-detection */ - baseTheme: ChatTheme + /** Current theme with customizations applied */ + theme: ChatTheme /** Resolved theme name (dark or light) */ resolvedThemeName: 'dark' | 'light' - /** Build a theme for a specific variant */ - buildVariantTheme: (variant: ThemeVariant) => ChatTheme } /** @@ -39,12 +30,6 @@ interface ThemeContextValue { */ const ThemeContext = createContext(null) -/** - * Variant context for nested components - * Allows parent components to set variant for their children - */ -const VariantContext = createContext('transparent') - /** * Theme Provider Props */ @@ -54,43 +39,29 @@ interface ThemeProviderProps { /** * Theme Provider Component - * Wraps app and provides theme context with variant support + * Wraps app and provides theme context */ export const ThemeProvider: React.FC = ({ children }) => { - // Detect system theme and get base theme + // Detect system theme const [resolvedThemeName] = useState<'dark' | 'light'>(() => detectSystemTheme()) - const [baseTheme] = useState(() => - cloneChatTheme(chatThemes[resolvedThemeName]), - ) - /** - * Build a theme for a specific variant - * Applies all theme layers: backgrounds, config overrides, custom colors, and plugins - */ - const buildVariantTheme = useCallback( - (variant: ThemeVariant): ChatTheme => { - const variantConfig = getVariantConfig(variant) - const clonedTheme = cloneChatTheme(baseTheme) - - return buildThemeWithVariant( - clonedTheme, - variant, - variantConfig, - resolvedThemeName, - themeConfig.customColors, - themeConfig.plugins, - ) - }, - [baseTheme, resolvedThemeName], - ) + // Build theme with customizations + const theme = useMemo(() => { + const baseTheme = cloneChatTheme(chatThemes[resolvedThemeName]) + return buildTheme( + baseTheme, + resolvedThemeName, + themeConfig.customColors, + themeConfig.plugins, + ) + }, [resolvedThemeName]) const contextValue = useMemo( () => ({ - baseTheme, + theme, resolvedThemeName, - buildVariantTheme, }), - [baseTheme, resolvedThemeName, buildVariantTheme], + [theme, resolvedThemeName], ) return ( @@ -101,35 +72,22 @@ export const ThemeProvider: React.FC = ({ children }) => { } /** - * Hook to access theme for the current component context - * Returns the theme variant set by the nearest parent component - * or the default transparent variant if none is set + * Hook to access theme for the current component * - * @returns Theme object for the current context + * @returns Theme object * * @example - * // In a regular component (gets transparent theme) - * const theme = useTheme() - * - * @example - * // Inside a ModalVariant component (gets modal theme with solid backgrounds) * const theme = useTheme() + * */ export const useTheme = (): ChatTheme => { const context = useContext(ThemeContext) - const variant = useContext(VariantContext) if (!context) { throw new Error('useTheme must be used within a ThemeProvider') } - // Memoize theme for this variant to avoid rebuilding on every render - const theme = useMemo( - () => context.buildVariantTheme(variant), - [context, variant], - ) - - return theme + return context.theme } /** @@ -138,7 +96,7 @@ export const useTheme = (): ChatTheme => { * * @example * const themeName = useResolvedThemeName() - * const logoColor = themeName === 'dark' ? '#ffffff' : '#000000' + * // Use if you need conditional logic based on light/dark mode */ export const useResolvedThemeName = (): 'dark' | 'light' => { const context = useContext(ThemeContext) @@ -149,36 +107,3 @@ export const useResolvedThemeName = (): 'dark' | 'light' => { return context.resolvedThemeName } - -/** - * Theme Variant Provider Props - */ -interface VariantProviderProps { - variant: ThemeVariant - children: React.ReactNode -} - -/** - * Theme Variant Provider Component - * Sets the theme variant for all children components - * Use this in base components (like BaseModal) to apply variant-specific theming - * - * @example - * export const BaseModal = ({ children }) => ( - * - * - * {children} - * - * - * ) - */ -export const VariantProvider: React.FC = ({ - variant, - children, -}) => { - return ( - - {children} - - ) -} diff --git a/cli/src/utils/theme-config.ts b/cli/src/utils/theme-config.ts index 2920ef30f..2c9bc433c 100644 --- a/cli/src/utils/theme-config.ts +++ b/cli/src/utils/theme-config.ts @@ -1,70 +1,11 @@ /** * Theme Configuration System * - * Tailwind-inspired theme configuration that allows components to use different - * theme variants (transparent, modal, embedded, custom) while maintaining the - * automatic light/dark mode detection. + * Provides plugin system and customization support for themes */ import type { ChatTheme } from '../types/theme-system' -/** - * Theme variant types for different component use cases - * - transparent: Default transparent backgrounds (terminal shows through) - * - modal: Solid backgrounds for overlay components like LoginModal - * - embedded: For future embedded views that need controlled backgrounds - * - custom: User-defined custom variant - */ -export type ThemeVariant = 'transparent' | 'modal' | 'embedded' | 'custom' - -/** - * Background color configuration for a theme variant - * Use 'auto' to automatically use #ffffff (light mode) or #000000 (dark mode) - * Use 'transparent' to keep transparent - * Use a hex color string for custom colors - */ -export type BackgroundColor = 'auto' | 'transparent' | string - -/** - * Configuration for background colors in a theme variant - */ -export interface ThemeVariantBackgrounds { - /** Main background color (replaces theme.background) */ - main?: BackgroundColor - /** Chrome background color (replaces theme.surface) */ - chrome?: BackgroundColor - /** Panel background color (replaces theme.surface) */ - panel?: BackgroundColor - /** Message background color (replaces theme.background) */ - message?: BackgroundColor - /** Input background color (replaces theme.inputBg) */ - input?: BackgroundColor - /** Focused input background color (replaces theme.inputFocusedBg) */ - inputFocused?: BackgroundColor - /** Agent content background color (replaces theme.background) */ - agent?: BackgroundColor - /** Accent background color (replaces theme.accentBg) */ - accent?: BackgroundColor - /** Agent focused background (replaces theme.agentFocusedBg) */ - agentFocused?: BackgroundColor - /** Agent toggle header background (replaces theme.agentToggleHeaderBg) */ - agentToggleHeader?: BackgroundColor - /** Agent toggle expanded background (replaces theme.agentToggleExpandedBg) */ - agentToggleExpanded?: BackgroundColor - /** Markdown code background (replaces theme.markdown?.codeBackground) */ - markdownCode?: BackgroundColor -} - -/** - * Configuration for a single theme variant - */ -export interface ThemeVariantConfig { - /** Background color overrides */ - backgrounds?: ThemeVariantBackgrounds - /** Additional theme property overrides */ - overrides?: Partial -} - /** * Plugin interface for extending theme system * Plugins can modify themes at runtime @@ -75,13 +16,11 @@ export interface ThemePlugin { /** * Apply plugin modifications to a theme * @param theme - The base theme - * @param variant - The current variant being built * @param mode - The detected light/dark mode * @returns Partial theme to merge */ apply: ( theme: ChatTheme, - variant: ThemeVariant, mode: 'dark' | 'light', ) => Partial } @@ -90,9 +29,7 @@ export interface ThemePlugin { * Main theme configuration interface */ export interface ThemeConfig { - /** Built-in theme variants */ - variants: Record - /** Global color overrides applied to all variants */ + /** Global color overrides applied to themes */ customColors?: Partial /** Registered plugins for theme extensions */ plugins?: ThemePlugin[] @@ -100,69 +37,9 @@ export interface ThemeConfig { /** * Default theme configuration - * This is the base configuration that can be extended by users */ export const defaultThemeConfig: ThemeConfig = { - variants: { - /** - * Transparent variant (default) - * All backgrounds are transparent, terminal background shows through - * This is the current default behavior - */ - transparent: { - backgrounds: { - // All backgrounds remain transparent (no overrides) - }, - }, - - /** - * Modal variant - * Solid backgrounds for overlay components - * Use 'auto' to get white in light mode, black in dark mode - */ - modal: { - backgrounds: { - main: 'auto', - chrome: 'auto', - panel: 'auto', - message: 'auto', - input: 'auto', - inputFocused: 'auto', - agent: 'auto', - accent: 'auto', - agentFocused: 'auto', - agentToggleHeader: 'auto', - markdownCode: 'auto', - }, - }, - - /** - * Embedded variant - * For future embedded views that need controlled backgrounds - * Similar to modal but with more selective solid backgrounds - */ - embedded: { - backgrounds: { - main: 'auto', - chrome: 'auto', - panel: 'auto', - }, - }, - - /** - * Custom variant - * Placeholder for user-defined custom themes - * Can be overridden via customColors in ThemeConfig - */ - custom: { - backgrounds: {}, - }, - }, - - // Global overrides (applied to all variants) customColors: {}, - - // Plugins (empty by default) plugins: [], } @@ -180,10 +57,6 @@ export const setThemeConfig = (config: Partial): void => { themeConfig = { ...defaultThemeConfig, ...config, - variants: { - ...defaultThemeConfig.variants, - ...config.variants, - }, plugins: [...(defaultThemeConfig.plugins ?? []), ...(config.plugins ?? [])], } } @@ -205,91 +78,8 @@ export const registerThemePlugin = (plugin: ThemePlugin): void => { } /** - * Get configuration for a specific variant - * @param variant - The variant to get config for - * @returns The variant configuration - */ -export const getVariantConfig = (variant: ThemeVariant): ThemeVariantConfig => { - return themeConfig.variants[variant] ?? themeConfig.variants.transparent -} - -/** - * Resolve a background color based on mode - * Converts 'auto' to white (light) or black (dark) - * @param color - Background color specification - * @param mode - Current theme mode (dark or light) - * @returns Resolved color string - */ -export const resolveBackgroundColor = ( - color: BackgroundColor | undefined, - mode: 'dark' | 'light', -): string | undefined => { - if (!color) return undefined - if (color === 'transparent') return 'transparent' - if (color === 'auto') { - return mode === 'dark' ? '#000000' : '#ffffff' - } - return color -} - -/** - * Mapping of theme properties to their corresponding background config keys - * Makes it easy to apply all background overrides without repetition - */ -const BACKGROUND_PROPERTY_MAPPING: Array< - [keyof ChatTheme, keyof ThemeVariantBackgrounds] -> = [ - ['background', 'main'], - ['surface', 'chrome'], // Chrome and panel both map to surface now - ['inputBg', 'input'], - ['inputFocusedBg', 'inputFocused'], - ['accentBg', 'accent'], - ['agentFocusedBg', 'agentFocused'], - ['agentToggleHeaderBg', 'agentToggleHeader'], - ['agentToggleExpandedBg', 'agentToggleExpanded'], - ['agentContentBg', 'agent'], -] - -/** - * Apply variant background overrides to a theme - * Resolves all 'auto' values based on the current light/dark mode - * @param theme - Base theme to apply backgrounds to - * @param variantConfig - Variant configuration with background overrides - * @param mode - Current theme mode (dark or light) - */ -export const applyVariantBackgrounds = ( - theme: ChatTheme, - variantConfig: ThemeVariantConfig, - mode: 'dark' | 'light', -): void => { - if (!variantConfig.backgrounds) return - - const bg = variantConfig.backgrounds - - // Apply all standard background properties via mapping - for (const [themeProp, bgProp] of BACKGROUND_PROPERTY_MAPPING) { - const bgValue = bg[bgProp] - if (bgValue !== undefined) { - const resolved = resolveBackgroundColor(bgValue, mode) - if (resolved !== undefined) { - ;(theme as any)[themeProp] = resolved - } - } - } - - // Handle markdown code background (nested property requires special handling) - if (bg.markdownCode !== undefined && theme.markdown) { - const resolved = resolveBackgroundColor(bg.markdownCode, mode) - if (resolved !== undefined) { - theme.markdown.codeBackground = resolved - } - } -} - -/** - * Resolve 'default' color values to fallback colors for ready-to-use theme + * Resolve 'default' color values to fallback colors * Components should never see 'default' - it's resolved during theme building - * We use sensible fallbacks that work in both light and dark modes */ const resolveThemeColors = (theme: ChatTheme, mode: 'dark' | 'light'): void => { const defaultFallback = mode === 'dark' ? '#ffffff' : '#000000' @@ -318,45 +108,32 @@ const resolveThemeColors = (theme: ChatTheme, mode: 'dark' | 'light'): void => { } /** - * Build a complete theme by layering overrides - * Applies variant backgrounds, config overrides, custom colors, and plugins - * All 'default' color values are resolved to undefined for ready-to-use theme + * Build a complete theme by applying custom colors and plugins + * All 'default' color values are resolved to actual colors * @param baseTheme - The base theme to start from - * @param variant - Theme variant to apply - * @param variantConfig - Configuration for the variant * @param mode - Current theme mode (dark or light) * @param customColors - Optional custom color overrides * @param plugins - Optional theme plugins to apply - * @returns Complete theme with all layers applied and colors resolved + * @returns Complete theme with all customizations applied */ -export const buildThemeWithVariant = ( +export const buildTheme = ( baseTheme: ChatTheme, - variant: ThemeVariant, - variantConfig: ThemeVariantConfig, mode: 'dark' | 'light', customColors?: Partial, plugins?: ThemePlugin[], ): ChatTheme => { - // Start with cloned base theme (cloning handled by caller to avoid circular dependency) + // Start with cloned base theme (cloning handled by caller) const theme = { ...baseTheme } - // Layer 1: Apply variant background overrides - applyVariantBackgrounds(theme, variantConfig, mode) - - // Layer 2: Apply variant-specific overrides - if (variantConfig.overrides) { - Object.assign(theme, variantConfig.overrides) - } - - // Layer 3: Apply global custom colors + // Layer 1: Apply global custom colors if (customColors) { Object.assign(theme, customColors) } - // Layer 4: Apply plugins + // Layer 2: Apply plugins if (plugins) { for (const plugin of plugins) { - const pluginOverrides = plugin.apply(theme, variant, mode) + const pluginOverrides = plugin.apply(theme, mode) Object.assign(theme, pluginOverrides) } } From 0368c6d6ea6831519abc43296b3113491494b4e6 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 17:10:14 -0800 Subject: [PATCH 36/41] Refactor theme system to use zustand for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace EventEmitter + React Context with zustand store: - Create theme-store.ts with zustand (consistent with login-store, chat-store) - Simplify use-theme.tsx to just hooks (110 lines → 34 lines!) - Remove ThemeProvider component entirely - No more Context needed - components just use useTheme() hook - Reactive theme detection on macOS now calls store.setThemeName() - Update test files to import theme-store instead of using ThemeProvider Benefits: - Consistent state management across codebase (all zustand) - Simpler mental model (no Context wrappers) - Better performance (selective subscriptions) - Less boilerplate (no Provider needed) All typechecks pass, all 50 unit tests pass, build succeeds. --- .../message-block.completion.test.tsx | 30 +++--- .../message-block.streaming.test.tsx | 26 +++-- .../__tests__/status-indicator.timer.test.tsx | 38 ++++---- cli/src/hooks/use-theme.tsx | 86 +---------------- cli/src/index.tsx | 8 +- cli/src/state/theme-store.ts | 49 ++++++++++ cli/src/utils/theme-system.ts | 95 ++++++++++++++++++- 7 files changed, 191 insertions(+), 141 deletions(-) create mode 100644 cli/src/state/theme-store.ts diff --git a/cli/src/components/__tests__/message-block.completion.test.tsx b/cli/src/components/__tests__/message-block.completion.test.tsx index e37b9ad32..74ada7aac 100644 --- a/cli/src/components/__tests__/message-block.completion.test.tsx +++ b/cli/src/components/__tests__/message-block.completion.test.tsx @@ -4,7 +4,7 @@ import { describe, test, expect } from 'bun:test' import { renderToStaticMarkup } from 'react-dom/server' import { MessageBlock } from '../message-block' -import { ThemeProvider } from '../../hooks/use-theme' +import '../../state/theme-store' // Initialize theme store import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' import type { MarkdownPalette } from '../../utils/markdown-renderer' @@ -52,14 +52,12 @@ const baseProps = { describe('MessageBlock completion time', () => { test('renders completion time and credits when complete', () => { const markup = renderToStaticMarkup( - - - , + , ) expect(markup).toContain('7s') @@ -68,14 +66,12 @@ describe('MessageBlock completion time', () => { test('omits completion line when not complete', () => { const markup = renderToStaticMarkup( - - - , + , ) expect(markup).not.toContain('7s') diff --git a/cli/src/components/__tests__/message-block.streaming.test.tsx b/cli/src/components/__tests__/message-block.streaming.test.tsx index ec5db67e1..a1c95d3ac 100644 --- a/cli/src/components/__tests__/message-block.streaming.test.tsx +++ b/cli/src/components/__tests__/message-block.streaming.test.tsx @@ -4,7 +4,7 @@ import { describe, test, expect } from 'bun:test' import { renderToStaticMarkup } from 'react-dom/server' import { MessageBlock } from '../message-block' -import { ThemeProvider } from '../../hooks/use-theme' +import '../../state/theme-store' // Initialize theme store import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' import type { MarkdownPalette } from '../../utils/markdown-renderer' @@ -52,13 +52,11 @@ const createTimer = (elapsedSeconds: number) => ({ describe('MessageBlock streaming indicator', () => { test('shows elapsed seconds while streaming', () => { const markup = renderToStaticMarkup( - - - , + , ) expect(markup).toContain('4s') @@ -66,13 +64,11 @@ describe('MessageBlock streaming indicator', () => { test('hides elapsed seconds when timer has not advanced', () => { const markup = renderToStaticMarkup( - - - , + , ) expect(markup).not.toContain('0s') diff --git a/cli/src/components/__tests__/status-indicator.timer.test.tsx b/cli/src/components/__tests__/status-indicator.timer.test.tsx index 28a52e946..5c5c0e327 100644 --- a/cli/src/components/__tests__/status-indicator.timer.test.tsx +++ b/cli/src/components/__tests__/status-indicator.timer.test.tsx @@ -13,7 +13,7 @@ import { } from 'bun:test' import { StatusIndicator } from '../status-indicator' -import { ThemeProvider } from '../../hooks/use-theme' +import '../../state/theme-store' // Initialize theme store import { renderToStaticMarkup } from 'react-dom/server' import * as codebuffClient from '../../utils/codebuff-client' @@ -39,25 +39,21 @@ describe('StatusIndicator timer rendering', () => { test('shows elapsed seconds when timer is active', () => { const markup = renderToStaticMarkup( - - - , + , ) expect(markup).toContain('5s') const inactiveMarkup = renderToStaticMarkup( - - - , + , ) expect(inactiveMarkup).toBe('') @@ -65,13 +61,11 @@ describe('StatusIndicator timer rendering', () => { test('clipboard message takes priority over timer output', () => { const markup = renderToStaticMarkup( - - - , + , ) expect(markup).toContain('Copied!') diff --git a/cli/src/hooks/use-theme.tsx b/cli/src/hooks/use-theme.tsx index 248e67dd0..831b221a2 100644 --- a/cli/src/hooks/use-theme.tsx +++ b/cli/src/hooks/use-theme.tsx @@ -1,75 +1,11 @@ /** - * Theme Hooks and Context + * Theme Hooks * - * Provides hook-based API for accessing themes + * Simple hooks for accessing theme from zustand store */ -import React, { - createContext, - useContext, - useState, - useMemo, -} from 'react' - -import { chatThemes, cloneChatTheme, detectSystemTheme } from '../utils/theme-system' +import { useThemeStore } from '../state/theme-store' import type { ChatTheme } from '../types/theme-system' -import { themeConfig, buildTheme } from '../utils/theme-config' - -/** - * Theme context value - */ -interface ThemeContextValue { - /** Current theme with customizations applied */ - theme: ChatTheme - /** Resolved theme name (dark or light) */ - resolvedThemeName: 'dark' | 'light' -} - -/** - * Theme context - */ -const ThemeContext = createContext(null) - -/** - * Theme Provider Props - */ -interface ThemeProviderProps { - children: React.ReactNode -} - -/** - * Theme Provider Component - * Wraps app and provides theme context - */ -export const ThemeProvider: React.FC = ({ children }) => { - // Detect system theme - const [resolvedThemeName] = useState<'dark' | 'light'>(() => detectSystemTheme()) - - // Build theme with customizations - const theme = useMemo(() => { - const baseTheme = cloneChatTheme(chatThemes[resolvedThemeName]) - return buildTheme( - baseTheme, - resolvedThemeName, - themeConfig.customColors, - themeConfig.plugins, - ) - }, [resolvedThemeName]) - - const contextValue = useMemo( - () => ({ - theme, - resolvedThemeName, - }), - [theme, resolvedThemeName], - ) - - return ( - - {children} - - ) -} /** * Hook to access theme for the current component @@ -81,13 +17,7 @@ export const ThemeProvider: React.FC = ({ children }) => { * */ export const useTheme = (): ChatTheme => { - const context = useContext(ThemeContext) - - if (!context) { - throw new Error('useTheme must be used within a ThemeProvider') - } - - return context.theme + return useThemeStore((state) => state.theme) } /** @@ -99,11 +29,5 @@ export const useTheme = (): ChatTheme => { * // Use if you need conditional logic based on light/dark mode */ export const useResolvedThemeName = (): 'dark' | 'light' => { - const context = useContext(ThemeContext) - - if (!context) { - throw new Error('useResolvedThemeName must be used within a ThemeProvider') - } - - return context.resolvedThemeName + return useThemeStore((state) => state.themeName) } diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 78043491c..898638083 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -11,7 +11,7 @@ import React from 'react' import { validateAgents } from '@codebuff/sdk' import { App } from './chat' -import { ThemeProvider } from './hooks/use-theme' +import './state/theme-store' // Initialize theme store and watchers import { getUserCredentials } from './utils/auth' import { getLoadedAgentsData } from './utils/local-agent-registry' import { clearLogFile } from './utils/logger' @@ -145,13 +145,11 @@ const AppWithAsyncAuth = () => { ) } -// Start app immediately with QueryClientProvider and ThemeProvider +// Start app immediately with QueryClientProvider function startApp() { render( - - - + , { backgroundColor: 'transparent', diff --git a/cli/src/state/theme-store.ts b/cli/src/state/theme-store.ts new file mode 100644 index 000000000..f49149459 --- /dev/null +++ b/cli/src/state/theme-store.ts @@ -0,0 +1,49 @@ +import { create } from 'zustand' + +import { chatThemes, cloneChatTheme, detectSystemTheme, initializeThemeWatcher } from '../utils/theme-system' +import type { ChatTheme, ThemeName } from '../types/theme-system' +import { themeConfig, buildTheme } from '../utils/theme-config' + +export type ThemeStoreState = { + /** Current theme name (dark or light) */ + themeName: ThemeName + /** Built theme with customizations applied */ + theme: ChatTheme +} + +type ThemeStoreActions = { + /** Update theme to a specific mode (dark or light) */ + setThemeName: (name: ThemeName) => void +} + +type ThemeStore = ThemeStoreState & ThemeStoreActions + +// Build initial theme +const initialThemeName = detectSystemTheme() +const initialTheme = buildTheme( + cloneChatTheme(chatThemes[initialThemeName]), + initialThemeName, + themeConfig.customColors, + themeConfig.plugins, +) + +export const useThemeStore = create((set) => ({ + themeName: initialThemeName, + theme: initialTheme, + + setThemeName: (name: ThemeName) => { + const baseTheme = cloneChatTheme(chatThemes[name]) + const theme = buildTheme( + baseTheme, + name, + themeConfig.customColors, + themeConfig.plugins, + ) + set({ themeName: name, theme }) + }, +})) + +// Initialize theme watcher to enable reactive updates from system theme changes +initializeThemeWatcher((name: ThemeName) => { + useThemeStore.getState().setThemeName(name) +}) diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 12434557e..8d82f290b 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -1,6 +1,7 @@ -import { existsSync, readFileSync, readdirSync, statSync } from 'fs' +import { existsSync, readFileSync, readdirSync, statSync, watch } from 'fs' import { homedir } from 'os' import { join } from 'path' +import { EventEmitter } from 'events' import { ChatTheme, MarkdownHeadingLevel, @@ -931,3 +932,95 @@ export const resolveThemeColor = ( return undefined } + +/** + * Reactive Theme Detection + * Watches for system theme changes and updates zustand store + */ + +let lastDetectedTheme: ThemeName | null = null +let themeStoreUpdater: ((name: ThemeName) => void) | null = null + +/** + * Initialize theme store updater + * Called by theme-store on initialization to enable reactive updates + * @param setter - Function to call when theme changes + */ +export const initializeThemeWatcher = (setter: (name: ThemeName) => void) => { + themeStoreUpdater = setter +} + +/** + * Recompute system theme and update store if it changed + * @param source - Source of the recomputation (for debugging) + */ +const recomputeSystemTheme = (source: string) => { + // Only recompute if theme is auto-detected (not explicitly set) + const envPreference = process.env.OPEN_TUI_THEME ?? process.env.OPENTUI_THEME + if (envPreference && envPreference.toLowerCase() !== 'opposite') { + // User explicitly set theme, don't react to system changes + return + } + + const newTheme = detectSystemTheme() + + if (lastDetectedTheme !== null && newTheme !== lastDetectedTheme) { + lastDetectedTheme = newTheme + // Update zustand store + if (themeStoreUpdater) { + themeStoreUpdater(newTheme) + } + } else if (lastDetectedTheme === null) { + // First detection, just store it + lastDetectedTheme = newTheme + } +} + +// Initialize on module load +lastDetectedTheme = detectSystemTheme() + +/** + * Setup macOS theme watchers + * Watches system preference files and triggers theme recomputation + */ +if (process.platform === 'darwin') { + const watchTargets = [ + join(homedir(), 'Library/Preferences/.GlobalPreferences.plist'), + join(homedir(), 'Library/Preferences/com.apple.Terminal.plist'), + ] + + for (const target of watchTargets) { + if (existsSync(target)) { + try { + const watcher = watch(target, { persistent: false }, (eventType) => { + // Debounce theme recomputation + setTimeout(() => recomputeSystemTheme(`fs:${target}:${eventType}`), 250) + }) + + watcher.on('error', () => { + // Silently ignore watcher errors + }) + + // Cleanup on process exit + process.on('exit', () => { + try { + watcher.close() + } catch { + // Ignore + } + }) + } catch { + // Silently ignore if we can't watch + } + } + } +} + +/** + * SIGUSR2 signal handler for manual theme refresh + * Users can send `kill -USR2 ` to force theme recomputation + */ +process.on('SIGUSR2', () => { + recomputeSystemTheme('signal:SIGUSR2') +}) + From 09f0bef4ff017b610ba16b70031d5ab93748cfbc Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 17:14:06 -0800 Subject: [PATCH 37/41] Fix type errors in BuildModeButtons component Changed wrap prop to wrapMode to match OpenTUI text component API. --- cli/src/components/build-mode-buttons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/components/build-mode-buttons.tsx b/cli/src/components/build-mode-buttons.tsx index 3c0fc7bbd..6ae1e9d59 100644 --- a/cli/src/components/build-mode-buttons.tsx +++ b/cli/src/components/build-mode-buttons.tsx @@ -29,7 +29,7 @@ export const BuildModeButtons = ({ }} onMouseDown={onBuildFast} > - + Build Fast @@ -43,7 +43,7 @@ export const BuildModeButtons = ({ }} onMouseDown={onBuildMax} > - + Build Max From 7c0bbe213f6fc5c624a8bcfb8f102cf08ed24a00 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 17:21:53 -0800 Subject: [PATCH 38/41] Consolidate ChatTheme properties to semantic essentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduced theme properties from 35 → 24 by removing redundant properties: Removed and replaced with semantics: - aiText, userText → foreground (unified text color) - aiTimestamp, userTimestamp → muted (timestamps are subdued) - agentPrefix, agentName → success (green elements) - agentContent, agentToggleHeaderText → foreground (default text) - logo → foreground - inputPlaceholder → muted - cursor → primary - link → info - linkActive → success - shimmer → info - accentBg, accentText → removed (redundant with primary) Kept essential context colors: - aiLine, userLine (visual differentiation) - Agent backgrounds (specific UI states) - Input bg/fg (component-specific) - Mode toggles (distinct UI elements) Benefits: - Easier to create custom themes (fewer properties to set) - More intuitive (use semantic colors for most things) - Cleaner theme definitions (less duplication) - Better alignment with design system best practices All typechecks pass, all 50 tests pass. --- cli/src/chat.tsx | 4 +- .../message-block.completion.test.tsx | 8 +-- .../message-block.streaming.test.tsx | 8 +-- cli/src/components/agent-branch-item.tsx | 14 ++--- cli/src/components/login-modal.tsx | 6 +- cli/src/components/message-block.tsx | 14 ++--- cli/src/components/multiline-input.tsx | 4 +- cli/src/components/shimmer-text.tsx | 4 +- cli/src/components/suggestion-menu.tsx | 4 +- cli/src/components/terminal-link.tsx | 4 +- cli/src/components/tool-call-item.tsx | 8 +-- cli/src/components/tool-item.tsx | 4 +- cli/src/components/tool-renderer.tsx | 2 +- cli/src/hooks/use-message-renderer.tsx | 26 ++++---- cli/src/types/theme-system.ts | 55 +---------------- cli/src/utils/theme-config.ts | 5 -- cli/src/utils/theme-system.ts | 59 ++++--------------- 17 files changed, 68 insertions(+), 161 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index a54ac9703..d62ea0972 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -227,7 +227,7 @@ export const App = ({ { type: 'text', content: '\n\n' + logoBlock, - color: theme.logo, + color: theme.foreground, }, ] @@ -1163,7 +1163,7 @@ export const App = ({ return output } - const messageAiTextColor = theme.aiText + const messageAiTextColor = theme.foreground const statusSecondaryColor = theme.secondary return ( diff --git a/cli/src/components/__tests__/message-block.completion.test.tsx b/cli/src/components/__tests__/message-block.completion.test.tsx index 74ada7aac..660b95f60 100644 --- a/cli/src/components/__tests__/message-block.completion.test.tsx +++ b/cli/src/components/__tests__/message-block.completion.test.tsx @@ -14,8 +14,8 @@ const basePalette = createMarkdownPalette(theme) const palette: MarkdownPalette = { ...basePalette, - inlineCodeFg: theme.aiText, - codeTextFg: theme.aiText, + inlineCodeFg: theme.foreground, + codeTextFg: theme.foreground, } const baseProps = { @@ -35,8 +35,8 @@ const baseProps = { elapsedSeconds: 0, startTime: null, }, - textColor: theme.aiText, - timestampColor: theme.aiTimestamp, + textColor: theme.foreground, + timestampColor: theme.muted, markdownOptions: { codeBlockWidth: 72, palette, diff --git a/cli/src/components/__tests__/message-block.streaming.test.tsx b/cli/src/components/__tests__/message-block.streaming.test.tsx index a1c95d3ac..46d0045c8 100644 --- a/cli/src/components/__tests__/message-block.streaming.test.tsx +++ b/cli/src/components/__tests__/message-block.streaming.test.tsx @@ -14,8 +14,8 @@ const basePalette = createMarkdownPalette(theme) const palette: MarkdownPalette = { ...basePalette, - inlineCodeFg: theme.aiText, - codeTextFg: theme.aiText, + inlineCodeFg: theme.foreground, + codeTextFg: theme.foreground, } const baseProps = { @@ -28,8 +28,8 @@ const baseProps = { timestamp: '12:00', completionTime: undefined, credits: undefined, - textColor: theme.aiText, - timestampColor: theme.aiTimestamp, + textColor: theme.foreground, + timestampColor: theme.muted, markdownOptions: { codeBlockWidth: 72, palette, diff --git a/cli/src/components/agent-branch-item.tsx b/cli/src/components/agent-branch-item.tsx index bfa122a90..77ea6774d 100644 --- a/cli/src/components/agent-branch-item.tsx +++ b/cli/src/components/agent-branch-item.tsx @@ -124,7 +124,7 @@ export const AgentBranchItem = ({ if (isTextRenderable(value)) { return ( @@ -205,9 +205,9 @@ export const AgentBranchItem = ({ width: '100%', }} > - Prompt + Prompt @@ -267,7 +267,7 @@ export const AgentBranchItem = ({ }} > {isStreaming ? streamingPreview : finishedPreview} @@ -293,11 +293,11 @@ export const AgentBranchItem = ({ marginBottom: content ? 1 : 0, }} > - + Prompt @@ -305,7 +305,7 @@ export const AgentBranchItem = ({ {content && ( Response diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 834224f54..384588807 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -249,7 +249,7 @@ export const LoginModal = ({ // Use custom hook for sheen animation const { applySheenToChar } = useSheenAnimation({ - logoColor: theme.logo, + logoColor: theme.foreground, terminalWidth: renderer?.width, sheenPosition, setSheenPosition, @@ -432,8 +432,8 @@ export const LoginModal = ({ text={loginUrl} maxWidth={maxUrlWidth} formatLines={formatLoginUrlLines} - color={hasClickedLink ? theme.linkActive : theme.link} - activeColor={theme.linkActive} + color={hasClickedLink ? theme.success : theme.info} + activeColor={theme.success} underlineOnHover={true} isActive={justCopied} onActivate={handleActivateLoginUrl} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index dd8d0bd6e..ef10430bc 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -72,7 +72,7 @@ export const MessageBlock = ({ registerAgentRef, }: MessageBlockProps): ReactNode => { const theme = useTheme() - const resolvedTextColor = textColor ?? theme.aiText + const resolvedTextColor = textColor ?? theme.foreground // Get elapsed time from timer for streaming AI messages const elapsedSeconds = timer.elapsedSeconds @@ -140,8 +140,8 @@ export const MessageBlock = ({ codeBlockWidth: Math.max(10, availableWidth - 12 - indentationOffset), palette: { ...markdownPalette, - inlineCodeFg: theme.agentContent, - codeTextFg: theme.agentContent, + inlineCodeFg: theme.foreground, + codeTextFg: theme.foreground, }, } } @@ -235,7 +235,7 @@ export const MessageBlock = ({ ? null : ( { const identifier = formatIdentifier(agent) return ( - + {` • ${identifier}`} ) @@ -481,7 +481,7 @@ export const MessageBlock = ({ typeof (nestedBlock as any).color === 'string' ? ((nestedBlock as any).color as string) : undefined - const nestedTextColor = explicitColor ?? theme.agentContent + const nestedTextColor = explicitColor ?? theme.foreground nodes.push( {nestedBlock.render({ - textColor: theme.agentContent, + textColor: theme.foreground, theme, })} , diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index a86d9d9ea..83384eb63 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -575,7 +575,7 @@ export const MultilineInput = forwardRef< ]) const inputColor = isPlaceholder - ? theme.inputPlaceholder + ? theme.muted : focused ? theme.inputFocusedFg : theme.inputFg @@ -591,7 +591,7 @@ export const MultilineInput = forwardRef< textStyle.attributes = textAttributes } - const cursorFg = theme.cursor + const cursorFg = theme.primary return ( { const attributes: number[] = [] diff --git a/cli/src/components/suggestion-menu.tsx b/cli/src/components/suggestion-menu.tsx index c89036a6c..cc8e7ab42 100644 --- a/cli/src/components/suggestion-menu.tsx +++ b/cli/src/components/suggestion-menu.tsx @@ -75,7 +75,7 @@ export const SuggestionMenu = ({ const textColor = isSelected ? theme.primary : theme.inputFg const descriptionColor = isSelected ? theme.primary - : theme.userTimestamp + : theme.muted return ( - {effectivePrefix} + {effectivePrefix} {item.label} {padding} diff --git a/cli/src/components/terminal-link.tsx b/cli/src/components/terminal-link.tsx index b7fa4ada5..5bf4e37ee 100644 --- a/cli/src/components/terminal-link.tsx +++ b/cli/src/components/terminal-link.tsx @@ -36,8 +36,8 @@ export const TerminalLink: React.FC = ({ const theme = useTheme() // Use theme colors as defaults if not provided - const linkColor = color ?? theme.link - const linkActiveColor = activeColor ?? theme.linkActive + const linkColor = color ?? theme.info + const linkActiveColor = activeColor ?? theme.success const [isHovered, setIsHovered] = useState(false) const displayLines = useMemo(() => { diff --git a/cli/src/components/tool-call-item.tsx b/cli/src/components/tool-call-item.tsx index fdac15216..3db81092e 100644 --- a/cli/src/components/tool-call-item.tsx +++ b/cli/src/components/tool-call-item.tsx @@ -67,7 +67,7 @@ const renderExpandedContent = ( if (isTextRenderable(value)) { return ( @@ -167,14 +167,14 @@ export const ToolCallItem = ({ {toggleLabel} {name} {titleSuffix ? ( {` ${titleSuffix}`} @@ -202,7 +202,7 @@ export const ToolCallItem = ({ }} > {collapsedPreviewText} diff --git a/cli/src/components/tool-item.tsx b/cli/src/components/tool-item.tsx index 2940066ca..ccd403f44 100644 --- a/cli/src/components/tool-item.tsx +++ b/cli/src/components/tool-item.tsx @@ -23,7 +23,7 @@ interface ToolItemProps { } const renderContent = (value: ReactNode, theme: ChatTheme): ReactNode => { - const contentFg = theme.agentContent + const contentFg = theme.foreground const contentAttributes = theme.messageTextAttributes !== undefined && theme.messageTextAttributes !== 0 ? theme.messageTextAttributes @@ -94,7 +94,7 @@ export const ToolItem = ({ const branchColor = theme.muted const branchAttributes = TextAttributes.DIM const titleColor = customTitleColor ?? theme.secondary - const previewColor = isStreaming ? theme.agentContent : theme.muted + const previewColor = isStreaming ? theme.foreground : theme.muted const baseTextAttributes = theme.messageTextAttributes ?? 0 const connectorSymbol = branchMeta.hasNext ? '├' : '└' const continuationPrefix = branchMeta.hasNext ? '│ ' : ' ' diff --git a/cli/src/components/tool-renderer.tsx b/cli/src/components/tool-renderer.tsx index 350ddcb67..5251c679f 100644 --- a/cli/src/components/tool-renderer.tsx +++ b/cli/src/components/tool-renderer.tsx @@ -133,7 +133,7 @@ const getListDirectoryRender = ( return {} } - const summaryColor = theme.agentContent + const summaryColor = theme.foreground const baseAttributes = theme.messageTextAttributes ?? 0 const getAttributes = (extra: number = 0): number | undefined => { const combined = baseAttributes | extra diff --git a/cli/src/hooks/use-message-renderer.tsx b/cli/src/hooks/use-message-renderer.tsx index de5f293cb..02f568dad 100644 --- a/cli/src/hooks/use-message-renderer.tsx +++ b/cli/src/hooks/use-message-renderer.tsx @@ -88,8 +88,8 @@ export const useMessageRenderer = ( const agentCodeBlockWidth = Math.max(10, availableWidth - 12) const agentPalette: MarkdownPalette = { ...markdownPalette, - inlineCodeFg: theme.agentContent, - codeTextFg: theme.agentContent, + inlineCodeFg: theme.foreground, + codeTextFg: theme.foreground, } const agentMarkdownOptions = { codeBlockWidth: agentCodeBlockWidth, @@ -161,7 +161,7 @@ export const useMessageRenderer = ( }} > - {fullPrefix} + {fullPrefix} - + {isCollapsed ? '▸ ' : '▾ '} {agentInfo.agentName} @@ -201,7 +201,7 @@ export const useMessageRenderer = ( > {isStreaming && isCollapsed && streamingPreview && ( {streamingPreview} @@ -218,7 +218,7 @@ export const useMessageRenderer = ( {!isCollapsed && ( {displayContent} @@ -273,15 +273,15 @@ export const useMessageRenderer = ( const isError = message.variant === 'error' const lineColor = isError ? 'red' : isAi ? theme.aiLine : theme.userLine const textColor = isError - ? theme.aiText + ? theme.foreground : isAi - ? theme.aiText - : theme.userText + ? theme.foreground + : theme.foreground const timestampColor = isError ? 'red' : isAi - ? theme.aiTimestamp - : theme.userTimestamp + ? theme.muted + : theme.muted const estimatedMessageWidth = availableWidth const codeBlockWidth = Math.max(10, estimatedMessageWidth - 8) const paletteForMessage: MarkdownPalette = { diff --git a/cli/src/types/theme-system.ts b/cli/src/types/theme-system.ts index 3975b6283..07acfaeb7 100644 --- a/cli/src/types/theme-system.ts +++ b/cli/src/types/theme-system.ts @@ -69,7 +69,7 @@ export interface ChatTheme { surfaceHover: string // ============================================================================ - // CONTEXT-SPECIFIC COLORS + // CONTEXT-SPECIFIC COLORS (Minimal - most use semantic colors) // ============================================================================ // AI/User differentiation @@ -79,34 +79,10 @@ export interface ChatTheme { /** User message indicator line color */ userLine: string - /** AI message text color */ - aiText: ThemeColor - - /** User message text color */ - userText: ThemeColor - - /** AI timestamp color */ - aiTimestamp: string - - /** User timestamp color */ - userTimestamp: string - - // Agent/Tool specific - /** Agent prefix symbol color (e.g., '>') */ - agentPrefix: string - - /** Agent name color */ - agentName: string - - /** Agent content text color */ - agentContent: ThemeColor - + // Agent backgrounds (specific states that don't map to semantics) /** Agent toggle header background */ agentToggleHeaderBg: string - /** Agent toggle header text */ - agentToggleHeaderText: ThemeColor - /** Agent toggle expanded background */ agentToggleExpandedBg: string @@ -129,13 +105,7 @@ export interface ChatTheme { /** Focused input text color */ inputFocusedFg: ThemeColor - /** Input placeholder text color */ - inputPlaceholder: ThemeColor - - /** Cursor color */ - cursor: string - - // Mode toggles + // Mode toggles (distinct UI elements) /** Fast mode toggle background */ modeFastBg: string @@ -154,25 +124,6 @@ export interface ChatTheme { /** Plan mode toggle text */ modePlanText: string - // Misc - /** Logo/branding color */ - logo: string - - /** Link color */ - link: string - - /** Active/clicked link color */ - linkActive: string - - /** Shimmer animation color */ - shimmer: string - - /** Accent background (for highlights, selections) */ - accentBg: string - - /** Accent text color */ - accentText: string - // ============================================================================ // MARKDOWN // ============================================================================ diff --git a/cli/src/utils/theme-config.ts b/cli/src/utils/theme-config.ts index 2c9bc433c..e20179fe1 100644 --- a/cli/src/utils/theme-config.ts +++ b/cli/src/utils/theme-config.ts @@ -98,13 +98,8 @@ const resolveThemeColors = (theme: ChatTheme, mode: 'dark' | 'light'): void => { // Resolve all ThemeColor properties to actual colors theme.foreground = resolve(theme.foreground) theme.muted = resolve(theme.muted) - theme.aiText = resolve(theme.aiText) - theme.userText = resolve(theme.userText) - theme.agentContent = resolve(theme.agentContent) theme.inputFg = resolve(theme.inputFg) theme.inputFocusedFg = resolve(theme.inputFocusedFg) - theme.inputPlaceholder = resolve(theme.inputPlaceholder, theme.secondary) - theme.agentToggleHeaderText = resolve(theme.agentToggleHeaderText) } /** diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index f1f75719b..ca54e68b5 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -464,10 +464,6 @@ export const getIDEThemeConfigPaths = (): string[] => { return [...paths] } - - - - type ChatThemeOverrides = Partial> & { markdown?: MarkdownThemeOverrides } @@ -706,20 +702,12 @@ const DEFAULT_CHAT_THEMES: Record = { surface: '#000000', surfaceHover: '#334155', - // AI/User context + // Context-specific aiLine: '#34d399', userLine: '#38bdf8', - aiText: '#f1f5f9', - userText: '#dbeafe', - aiTimestamp: '#4ade80', - userTimestamp: '#60a5fa', - // Agent context - agentPrefix: '#22c55e', - agentName: '#4ade80', - agentContent: '#ffffff', + // Agent backgrounds agentToggleHeaderBg: '#f97316', - agentToggleHeaderText: '#ffffff', agentToggleExpandedBg: '#1d4ed8', agentFocusedBg: '#334155', agentContentBg: '#000000', @@ -729,8 +717,6 @@ const DEFAULT_CHAT_THEMES: Record = { inputFg: '#f5f5f5', inputFocusedBg: '#000000', inputFocusedFg: '#ffffff', - inputPlaceholder: '#a3a3a3', - cursor: '#22c55e', // Mode toggles modeFastBg: '#f97316', @@ -740,14 +726,6 @@ const DEFAULT_CHAT_THEMES: Record = { modePlanBg: '#1e40af', modePlanText: '#1e40af', - // Misc - logo: '#ffffff', - link: '#38bdf8', - linkActive: '#22c55e', - shimmer: '#38bdf8', - accentBg: '#facc15', - accentText: '#1c1917', - // Markdown markdown: { codeBackground: '#1f2933', @@ -789,17 +767,9 @@ const DEFAULT_CHAT_THEMES: Record = { // AI/User context aiLine: '#059669', userLine: '#3b82f6', - aiText: '#111827', - userText: '#1f2937', - aiTimestamp: '#047857', - userTimestamp: '#2563eb', // Agent context - agentPrefix: '#059669', - agentName: '#047857', - agentContent: '#111827', agentToggleHeaderBg: '#ea580c', - agentToggleHeaderText: '#ffffff', agentToggleExpandedBg: '#1d4ed8', agentFocusedBg: '#f3f4f6', agentContentBg: '#ffffff', @@ -809,8 +779,6 @@ const DEFAULT_CHAT_THEMES: Record = { inputFg: '#111827', inputFocusedBg: '#ffffff', inputFocusedFg: '#000000', - inputPlaceholder: '#9ca3af', - cursor: '#3b82f6', // Mode toggles modeFastBg: '#f97316', @@ -820,14 +788,6 @@ const DEFAULT_CHAT_THEMES: Record = { modePlanBg: '#1e40af', modePlanText: '#1e40af', - // Misc - logo: '#000000', - link: '#3b82f6', - linkActive: '#059669', - shimmer: '#3b82f6', - accentBg: '#f59e0b', - accentText: '#111827', - // Markdown markdown: { codeBackground: '#f3f4f6', @@ -872,7 +832,7 @@ export const createMarkdownPalette = (theme: ChatTheme): MarkdownPalette => { const overrides = theme.markdown?.headingFg ?? {} return { - inlineCodeFg: theme.markdown?.inlineCodeFg ?? theme.aiText, + inlineCodeFg: theme.markdown?.inlineCodeFg ?? theme.foreground, codeBackground: theme.markdown?.codeBackground ?? theme.background, codeHeaderFg: theme.markdown?.codeHeaderFg ?? theme.secondary, headingFg: { @@ -880,11 +840,10 @@ export const createMarkdownPalette = (theme: ChatTheme): MarkdownPalette => { ...overrides, }, listBulletFg: theme.markdown?.listBulletFg ?? theme.secondary, - blockquoteBorderFg: - theme.markdown?.blockquoteBorderFg ?? theme.secondary, - blockquoteTextFg: theme.markdown?.blockquoteTextFg ?? theme.aiText, + blockquoteBorderFg: theme.markdown?.blockquoteBorderFg ?? theme.secondary, + blockquoteTextFg: theme.markdown?.blockquoteTextFg ?? theme.foreground, dividerFg: theme.markdown?.dividerFg ?? theme.secondary, - codeTextFg: theme.markdown?.codeTextFg ?? theme.aiText, + codeTextFg: theme.markdown?.codeTextFg ?? theme.foreground, codeMonochrome: theme.markdown?.codeMonochrome ?? true, } } @@ -998,7 +957,10 @@ if (process.platform === 'darwin') { try { const watcher = watch(target, { persistent: false }, (eventType) => { // Debounce theme recomputation - setTimeout(() => recomputeSystemTheme(`fs:${target}:${eventType}`), 250) + setTimeout( + () => recomputeSystemTheme(`fs:${target}:${eventType}`), + 250, + ) }) watcher.on('error', () => { @@ -1027,4 +989,3 @@ if (process.platform === 'darwin') { process.on('SIGUSR2', () => { recomputeSystemTheme('signal:SIGUSR2') }) - From 986aed56399169d34b0a61da316d2cdc931da9c6 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 17:43:59 -0800 Subject: [PATCH 39/41] fix(npm-app): add type annotations to error handlers in browser-runner.ts --- npm-app/src/browser-runner.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/npm-app/src/browser-runner.ts b/npm-app/src/browser-runner.ts index df8623c57..dcb59c4f0 100644 --- a/npm-app/src/browser-runner.ts +++ b/npm-app/src/browser-runner.ts @@ -570,12 +570,13 @@ export class BrowserRunner { }) // Page errors - this.page.on('pageerror', (err) => { + this.page.on('pageerror', (err: unknown) => { + const error = err as Error this.logs.push({ type: 'error', - message: err.message, + message: error.message, timestamp: Date.now(), - stack: err.stack, + stack: error.stack, source: 'browser', }) this.jsErrorCount++ @@ -767,7 +768,7 @@ export class BrowserRunner { this.page = null try { await browser.close() - } catch (err) { + } catch (err: unknown) { console.error('Error closing browser:', err) logger.error( { From b2473a39829cfa3a9784e58f79c181c2708ca1e8 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 17:56:57 -0800 Subject: [PATCH 40/41] fix(cli): improve input cursor visibility with blue color and highlight background --- cli/src/components/multiline-input.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 83384eb63..e48a699a2 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -591,7 +591,8 @@ export const MultilineInput = forwardRef< textStyle.attributes = textAttributes } - const cursorFg = theme.primary + const cursorFg = theme.info + const highlightBg = '#7dd3fc' // Lighter blue for highlight background return ( {activeChar === ' ' ? '\u00a0' : activeChar} From b8bd38fd55a3fca3f7739fecb8cb7147b583f8b3 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 3 Nov 2025 18:00:57 -0800 Subject: [PATCH 41/41] Fix theme toggle not working on second change Replace polling with event-driven directory watching for more reliable theme detection on macOS. The issue was that watching files directly doesn't reliably catch key deletions in plist files (which happens when switching from dark to light mode). Changes: - Watch parent directories instead of individual files for better reliability - Use fs.watch (event-driven) instead of fs.watchFile (polling) - Add IDE config file watchers (VS Code, Zed, JetBrains) in addition to system preferences - Simplify theme update logic to always call updater and let store decide if update needed --- cli/src/state/theme-store.ts | 1 + cli/src/utils/theme-system.ts | 78 ++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/cli/src/state/theme-store.ts b/cli/src/state/theme-store.ts index f49149459..cb36c7534 100644 --- a/cli/src/state/theme-store.ts +++ b/cli/src/state/theme-store.ts @@ -45,5 +45,6 @@ export const useThemeStore = create((set) => ({ // Initialize theme watcher to enable reactive updates from system theme changes initializeThemeWatcher((name: ThemeName) => { + // Always call setThemeName - it will handle building and updating the theme useThemeStore.getState().setThemeName(name) }) diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index ca54e68b5..c9a7b99e3 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -1,17 +1,15 @@ import { existsSync, readFileSync, readdirSync, statSync, watch } from 'fs' import { homedir } from 'os' -import { join } from 'path' -import { EventEmitter } from 'events' -import { +import { dirname, join } from 'path' + +import type { MarkdownPalette } from './markdown-renderer' +import type { ChatTheme, MarkdownHeadingLevel, MarkdownThemeOverrides, ThemeName, - ThemeColor, } from '../types/theme-system' -import type { MarkdownPalette } from './markdown-renderer' - // Re-export types for backward compatibility export type { ChatTheme, ThemeColor } from '../types/theme-system' @@ -927,15 +925,10 @@ const recomputeSystemTheme = (source: string) => { const newTheme = detectSystemTheme() - if (lastDetectedTheme !== null && newTheme !== lastDetectedTheme) { - lastDetectedTheme = newTheme - // Update zustand store - if (themeStoreUpdater) { - themeStoreUpdater(newTheme) - } - } else if (lastDetectedTheme === null) { - // First detection, just store it - lastDetectedTheme = newTheme + // Always call the updater and let it decide if an update is needed + lastDetectedTheme = newTheme + if (themeStoreUpdater) { + themeStoreUpdater(newTheme) } } @@ -943,38 +936,47 @@ const recomputeSystemTheme = (source: string) => { lastDetectedTheme = detectSystemTheme() /** - * Setup macOS theme watchers - * Watches system preference files and triggers theme recomputation + * Setup file watchers for theme changes + * Watches parent directories which reliably catches all file modifications */ -if (process.platform === 'darwin') { - const watchTargets = [ - join(homedir(), 'Library/Preferences/.GlobalPreferences.plist'), - join(homedir(), 'Library/Preferences/com.apple.Terminal.plist'), - ] +const setupFileWatchers = () => { + const watchTargets: string[] = [] + const watchedDirs = new Set() + + // macOS system preferences + if (process.platform === 'darwin') { + watchTargets.push( + join(homedir(), 'Library/Preferences/.GlobalPreferences.plist'), + join(homedir(), 'Library/Preferences/com.apple.Terminal.plist'), + ) + } + // IDE config files that we should watch + const ideConfigPaths = getIDEThemeConfigPaths() + watchTargets.push(...ideConfigPaths) + + // Watch parent directories instead of individual files + // Directory watches are more reliable for catching all modifications including plist key deletions for (const target of watchTargets) { if (existsSync(target)) { + const parentDir = dirname(target) + + // Only watch each directory once + if (watchedDirs.has(parentDir)) continue + watchedDirs.add(parentDir) + try { - const watcher = watch(target, { persistent: false }, (eventType) => { - // Debounce theme recomputation - setTimeout( - () => recomputeSystemTheme(`fs:${target}:${eventType}`), - 250, - ) + // Watch the directory - catches all file modifications + const watcher = watch(parentDir, { persistent: false }, (eventType, filename) => { + // Only respond to changes affecting our target files + if (filename && watchTargets.some((t) => t.endsWith(filename))) { + recomputeSystemTheme(`watch:${join(parentDir, filename)}:${eventType}`) + } }) watcher.on('error', () => { // Silently ignore watcher errors }) - - // Cleanup on process exit - process.on('exit', () => { - try { - watcher.close() - } catch { - // Ignore - } - }) } catch { // Silently ignore if we can't watch } @@ -982,6 +984,8 @@ if (process.platform === 'darwin') { } } +setupFileWatchers() + /** * SIGUSR2 signal handler for manual theme refresh * Users can send `kill -USR2 ` to force theme recomputation