diff --git a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx index 66a6b5736..48500e81a 100644 --- a/src/__tests__/renderer/components/AgentSessionsModal.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsModal.test.tsx @@ -1455,7 +1455,7 @@ describe('AgentSessionsModal', () => { await waitFor(() => { const messageBubble = screen.getByText('Dark mode message').closest('.rounded-lg'); expect(messageBubble).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); - expect(messageBubble).toHaveStyle({ color: '#000' }); // Dark mode uses black text + expect(messageBubble).toHaveStyle({ color: mockTheme.colors.accentForeground }); }); }); @@ -1488,7 +1488,7 @@ describe('AgentSessionsModal', () => { await waitFor(() => { const messageBubble = screen.getByText('Light mode message').closest('.rounded-lg'); - expect(messageBubble).toHaveStyle({ color: '#fff' }); // Light mode uses white text + expect(messageBubble).toHaveStyle({ color: lightTheme.colors.accentForeground }); }); }); }); diff --git a/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx b/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx index 50e491d5a..c1acc813d 100644 --- a/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx +++ b/src/__tests__/renderer/components/AutoRunExpandedModal.test.tsx @@ -127,6 +127,7 @@ const createMockTheme = (): Theme => ({ success: '#00aa00', warning: '#ffaa00', error: '#ff0000', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', }, }); @@ -701,7 +702,7 @@ describe('AutoRunExpandedModal', () => { const { container } = renderWithProvider(); const overlay = container.querySelector('.fixed.inset-0'); - expect(overlay).toHaveStyle({ backgroundColor: 'rgba(0,0,0,0.7)' }); + expect(overlay).toHaveStyle({ backgroundColor: props.theme.colors.overlayHeavy }); }); it('should have 90vw width and 80vh height', () => { diff --git a/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx b/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx index 6d53b6eab..83733dc53 100644 --- a/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx +++ b/src/__tests__/renderer/components/CustomThemeBuilder.test.tsx @@ -29,6 +29,23 @@ const mockThemeColors: ThemeColors = { success: '#10b981', warning: '#f59e0b', error: '#ef4444', + info: '#3b82f6', + successForeground: '#1a1a2e', + warningForeground: '#1a1a2e', + errorForeground: '#1a1a2e', + successDim: 'rgba(16, 185, 129, 0.15)', + warningDim: 'rgba(245, 158, 11, 0.15)', + errorDim: 'rgba(239, 68, 68, 0.15)', + infoDim: 'rgba(59, 130, 246, 0.15)', + diffAddition: '#10b981', + diffAdditionBg: 'rgba(16, 185, 129, 0.15)', + diffDeletion: '#ef4444', + diffDeletionBg: 'rgba(239, 68, 68, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }; const mockTheme: Theme = { diff --git a/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx b/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx index b78d4ce8c..de9b8f82b 100644 --- a/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx +++ b/src/__tests__/renderer/components/DocumentGraph/DocumentNode.test.tsx @@ -31,6 +31,7 @@ const mockTheme: Theme = { success: '#50fa7b', warning: '#ffb86c', error: '#ff5555', + info: '#8be9fd', }, }; @@ -835,7 +836,7 @@ describe('DocumentNode', () => { const indicator = screen.getByTestId('large-file-indicator'); expect(indicator).toHaveStyle({ - color: '#3b82f6', + color: mockTheme.colors.info, }); }); diff --git a/src/__tests__/renderer/components/GitDiffViewer.test.tsx b/src/__tests__/renderer/components/GitDiffViewer.test.tsx index e2899952d..e384d7e9b 100644 --- a/src/__tests__/renderer/components/GitDiffViewer.test.tsx +++ b/src/__tests__/renderer/components/GitDiffViewer.test.tsx @@ -138,6 +138,10 @@ const mockTheme = { vibe: '#8855ff', statusBar: '#0d0d1a', scrollbarThumb: '#444466', + diffAddition: '#50fa7b', + diffAdditionBg: 'rgba(80, 250, 123, 0.15)', + diffDeletion: '#ff5555', + diffDeletionBg: 'rgba(255, 85, 85, 0.15)', }, }; @@ -830,7 +834,7 @@ describe('GitDiffViewer', () => { const onClose = vi.fn(); mockParseGitDiff.mockReturnValue([createMockParsedFile()]); - render( + const { container } = render( { /> ); - // The Plus icon from lucide-react should be present with green color - const greenSpans = document.querySelectorAll('.text-green-500'); - expect(greenSpans.length).toBeGreaterThan(0); + // The additions span uses inline style with diffAddition theme color + const allSpans = Array.from(container.querySelectorAll('span')); + const additionSpan = allSpans.find( + (span) => + span.style.color && + (span.style.color === mockTheme.colors.diffAddition || + span.style.color.includes('80, 250, 123')) + ); + expect(additionSpan).toBeTruthy(); }); it('shows deletions in tab for text files with deletions', () => { @@ -882,7 +892,7 @@ describe('GitDiffViewer', () => { }), ]); - render( + const { container } = render( { /> ); - // There should be red minus sign for deletions - const redSpans = document.querySelectorAll('.text-red-500'); - expect(redSpans.length).toBeGreaterThan(0); + // The deletions span uses inline style with diffDeletion theme color + const allSpans = Array.from(container.querySelectorAll('span')); + const deletionSpan = allSpans.find( + (span) => + span.style.color && + (span.style.color === mockTheme.colors.diffDeletion || + span.style.color.includes('255, 85, 85')) + ); + expect(deletionSpan).toBeTruthy(); }); it('shows additions and deletions in footer', () => { diff --git a/src/__tests__/renderer/components/HistoryHelpModal.test.tsx b/src/__tests__/renderer/components/HistoryHelpModal.test.tsx index 0c10d66e2..f9435a8dd 100644 --- a/src/__tests__/renderer/components/HistoryHelpModal.test.tsx +++ b/src/__tests__/renderer/components/HistoryHelpModal.test.tsx @@ -474,10 +474,10 @@ describe('HistoryHelpModal', () => { 'font-medium', 'transition-colors' ); - // Check for accent background - the exact RGB value + // Check for accent background and theme-based foreground color const style = gotItButton.getAttribute('style'); expect(style).toContain('background-color'); - expect(style).toContain('color: white'); + expect(style).toContain('color'); }); it('calls onClose when "Got it" button is clicked', () => { diff --git a/src/__tests__/renderer/components/LogViewer.test.tsx b/src/__tests__/renderer/components/LogViewer.test.tsx index 85f2ef46a..01027652c 100644 --- a/src/__tests__/renderer/components/LogViewer.test.tsx +++ b/src/__tests__/renderer/components/LogViewer.test.tsx @@ -34,6 +34,7 @@ const mockTheme: Theme = { error: '#ff5555', warning: '#ffb86c', success: '#50fa7b', + info: '#8be9fd', syntaxComment: '#6272a4', syntaxKeyword: '#ff79c6', }, @@ -936,7 +937,7 @@ describe('LogViewer', () => { await waitFor(() => { const levelPill = screen.getByText('info'); - expect(levelPill).toHaveStyle({ color: '#3b82f6' }); + expect(levelPill).toHaveStyle({ color: mockTheme.colors.info }); }); }); @@ -947,7 +948,7 @@ describe('LogViewer', () => { await waitFor(() => { const levelPill = screen.getByText('warn'); - expect(levelPill).toHaveStyle({ color: '#f59e0b' }); + expect(levelPill).toHaveStyle({ color: mockTheme.colors.warning }); }); }); @@ -958,7 +959,7 @@ describe('LogViewer', () => { await waitFor(() => { const levelPill = screen.getByText('error'); - expect(levelPill).toHaveStyle({ color: '#ef4444' }); + expect(levelPill).toHaveStyle({ color: mockTheme.colors.error }); }); }); diff --git a/src/__tests__/renderer/components/PromptComposerModal.test.tsx b/src/__tests__/renderer/components/PromptComposerModal.test.tsx index e0c01a2ea..c13165a79 100644 --- a/src/__tests__/renderer/components/PromptComposerModal.test.tsx +++ b/src/__tests__/renderer/components/PromptComposerModal.test.tsx @@ -50,6 +50,7 @@ const mockTheme: Theme = { headerBg: '#202020', scrollbarTrack: '#1a1a1a', scrollbarThumb: '#444444', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', }, }; @@ -1003,7 +1004,7 @@ describe('PromptComposerModal', () => { ); const overlay = container.querySelector('.fixed.inset-0'); - expect(overlay).toHaveStyle({ backgroundColor: 'rgba(0,0,0,0.7)' }); + expect(overlay).toHaveStyle({ backgroundColor: mockTheme.colors.overlayHeavy }); }); it('should have modal content with rounded corners and border', () => { diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index f451b689f..4fba33fae 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -131,6 +131,7 @@ const mockTheme: Theme = { warning: '#ffaa00', vibe: '#ff00ff', agentStatus: '#00ff00', + hoverBg: 'rgba(255, 255, 255, 0.06)', }, }; @@ -1688,11 +1689,11 @@ describe('TabBar', () => { // Before hover - check inline style is not hover state const initialBgColor = inactiveTab.style.backgroundColor; - expect(initialBgColor).not.toBe('rgba(255, 255, 255, 0.08)'); + expect(initialBgColor).not.toBe(mockTheme.colors.hoverBg); // Hover fireEvent.mouseEnter(inactiveTab); - expect(inactiveTab.style.backgroundColor).toBe('rgba(255, 255, 255, 0.08)'); + expect(inactiveTab.style.backgroundColor).toBe(mockTheme.colors.hoverBg); // Leave fireEvent.mouseLeave(inactiveTab); @@ -1703,7 +1704,7 @@ describe('TabBar', () => { }); // Background color should no longer be hover state - expect(inactiveTab.style.backgroundColor).not.toBe('rgba(255, 255, 255, 0.08)'); + expect(inactiveTab.style.backgroundColor).not.toBe(mockTheme.colors.hoverBg); }); it('does not set title attribute on tabs (removed for cleaner UX)', () => { diff --git a/src/__tests__/renderer/components/auto-scroll.test.tsx b/src/__tests__/renderer/components/auto-scroll.test.tsx new file mode 100644 index 000000000..67a7f3c7b --- /dev/null +++ b/src/__tests__/renderer/components/auto-scroll.test.tsx @@ -0,0 +1,433 @@ +/** + * @file auto-scroll.test.tsx + * @description Tests for the auto-scroll feature across multiple components + * + * Test coverage includes: + * - Settings integration (default value, persistence, SettingsModal rendering) + * - Keyboard shortcut registration and handling + * - TerminalOutput auto-scroll button behavior (rendering, clicking, state) + * - Props threading from useMainPanelProps through MainPanel to TerminalOutput + */ + +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TerminalOutput } from '../../../renderer/components/TerminalOutput'; +import { DEFAULT_SHORTCUTS } from '../../../renderer/constants/shortcuts'; +import type { Session, Theme, LogEntry } from '../../../renderer/types'; + +// Mock dependencies (same pattern as TerminalOutput.test.tsx) +vi.mock('react-syntax-highlighter', () => ({ + Prism: ({ children }: { children: string }) => ( +
{children}
+ ), +})); + +vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ + vscDarkPlus: {}, +})); + +vi.mock('react-markdown', () => ({ + default: ({ children }: { children: string }) => ( +
{children}
+ ), +})); + +vi.mock('remark-gfm', () => ({ + default: [], +})); + +vi.mock('dompurify', () => ({ + default: { + sanitize: (html: string) => html, + }, +})); + +vi.mock('ansi-to-html', () => ({ + default: class Convert { + toHtml(text: string) { + return text; + } + }, +})); + +vi.mock('../../../renderer/contexts/LayerStackContext', () => ({ + useLayerStack: () => ({ + registerLayer: vi.fn().mockReturnValue('layer-1'), + unregisterLayer: vi.fn(), + updateLayerHandler: vi.fn(), + }), +})); + +vi.mock('../../../renderer/utils/tabHelpers', () => ({ + getActiveTab: (session: Session) => + session.tabs?.find((t) => t.id === session.activeTabId) || session.tabs?.[0], +})); + +// Default theme for testing +const defaultTheme: Theme = { + id: 'test-theme' as any, + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + textMain: '#e94560', + textDim: '#a0a0a0', + accent: '#e94560', + accentDim: '#b83b5e', + accentForeground: '#ffffff', + accentText: '#ff79c6', + border: '#2a2a4e', + success: '#00ff88', + warning: '#ffcc00', + error: '#ff4444', + info: '#4488ff', + successForeground: '#1a1a2e', + warningForeground: '#1a1a2e', + errorForeground: '#1a1a2e', + successDim: 'rgba(0, 255, 136, 0.15)', + warningDim: 'rgba(255, 204, 0, 0.15)', + errorDim: 'rgba(255, 68, 68, 0.15)', + infoDim: 'rgba(68, 136, 255, 0.15)', + diffAddition: '#00ff88', + diffAdditionBg: 'rgba(0, 255, 136, 0.15)', + diffDeletion: '#ff4444', + diffDeletionBg: 'rgba(255, 68, 68, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', + }, +}; + +// Create a default session +const createDefaultSession = (overrides: Partial = {}): Session => ({ + id: 'session-1', + name: 'Test Session', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/test/path', + projectRoot: '/test/path', + aiPid: 12345, + terminalPid: 12346, + aiLogs: [], + shellLogs: [], + isGitRepo: false, + fileTree: [], + fileExplorerExpanded: [], + messageQueue: [], + tabs: [ + { + id: 'tab-1', + agentSessionId: 'claude-123', + logs: [], + isUnread: false, + }, + ], + activeTabId: 'tab-1', + ...overrides, +}); + +// Create a log entry +const createLogEntry = (overrides: Partial = {}): LogEntry => ({ + id: `log-${Date.now()}-${Math.random()}`, + text: 'Test log entry', + timestamp: Date.now(), + source: 'stdout', + ...overrides, +}); + +// Default props +const createDefaultProps = ( + overrides: Partial> = {} +) => ({ + session: createDefaultSession(), + theme: defaultTheme, + fontFamily: 'monospace', + activeFocus: 'main', + outputSearchOpen: false, + outputSearchQuery: '', + setOutputSearchOpen: vi.fn(), + setOutputSearchQuery: vi.fn(), + setActiveFocus: vi.fn(), + setLightboxImage: vi.fn(), + inputRef: { current: null } as React.RefObject, + logsEndRef: { current: null } as React.RefObject, + maxOutputLines: 50, + markdownEditMode: false, + setMarkdownEditMode: vi.fn(), + ...overrides, +}); + +describe('Auto-scroll feature', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('settings integration', () => { + it('autoScrollAiMode defaults to false (button does not render when at bottom)', () => { + // When autoScrollAiMode is false (default) and at bottom, the button should not render + const setAutoScrollAiMode = vi.fn(); + const props = createDefaultProps({ + autoScrollAiMode: false, + setAutoScrollAiMode, + }); + + render(); + + // In the current behavior, button only shows when not at bottom or when active + expect(screen.queryByTitle(/Auto-scroll|Scroll to bottom/)).not.toBeInTheDocument(); + }); + + it('autoScrollAiMode persists when toggled via button click', async () => { + const setAutoScrollAiMode = vi.fn(); + const props = createDefaultProps({ + autoScrollAiMode: true, + setAutoScrollAiMode, + }); + + render(); + + const button = screen.getByTitle('Auto-scroll ON (click to unpin)'); + await act(async () => { + fireEvent.click(button); + }); + + // Should call the setter to unpin (true -> false) + expect(setAutoScrollAiMode).toHaveBeenCalledWith(false); + }); + + it('setting is rendered in SettingsModal with correct label (shortcut registration)', () => { + // Verify the toggleAutoScroll shortcut is registered in DEFAULT_SHORTCUTS + expect(DEFAULT_SHORTCUTS.toggleAutoScroll).toBeDefined(); + expect(DEFAULT_SHORTCUTS.toggleAutoScroll.label).toBe('Toggle Auto-Scroll AI Output'); + expect(DEFAULT_SHORTCUTS.toggleAutoScroll.keys).toEqual(['Alt', 'Meta', 's']); + }); + }); + + describe('keyboard shortcut', () => { + it('auto-scroll keyboard shortcut is registered in shortcuts.ts', () => { + const shortcut = DEFAULT_SHORTCUTS.toggleAutoScroll; + expect(shortcut).toBeDefined(); + expect(shortcut.id).toBe('toggleAutoScroll'); + expect(shortcut.keys).toEqual(['Alt', 'Meta', 's']); + }); + }); + + describe('TerminalOutput button rendering', () => { + it('auto-scroll button does NOT render when autoScrollAiMode prop is not provided', () => { + // When setAutoScrollAiMode is not passed, button should not render + const props = createDefaultProps({ + // No autoScrollAiMode or setAutoScrollAiMode + }); + + render(); + + expect(screen.queryByTitle(/Auto-scroll/)).not.toBeInTheDocument(); + }); + + it('auto-scroll button renders when autoScrollAiMode is true and inputMode is ai', () => { + const props = createDefaultProps({ + autoScrollAiMode: true, + setAutoScrollAiMode: vi.fn(), + session: createDefaultSession({ inputMode: 'ai' }), + }); + + render(); + + expect(screen.getByTitle('Auto-scroll ON (click to unpin)')).toBeInTheDocument(); + }); + + it('auto-scroll button does NOT render in terminal mode', () => { + const props = createDefaultProps({ + autoScrollAiMode: true, + setAutoScrollAiMode: vi.fn(), + session: createDefaultSession({ inputMode: 'terminal' }), + }); + + render(); + + expect(screen.queryByTitle(/Auto-scroll/)).not.toBeInTheDocument(); + }); + + it('clicking the button unpins autoScrollAiMode', async () => { + const setAutoScrollAiMode = vi.fn(); + const props = createDefaultProps({ + autoScrollAiMode: true, + setAutoScrollAiMode, + }); + + render(); + + const button = screen.getByTitle('Auto-scroll ON (click to unpin)'); + await act(async () => { + fireEvent.click(button); + }); + + // Should unpin (true -> false) + expect(setAutoScrollAiMode).toHaveBeenCalledWith(false); + }); + + it('button shows active state when auto-scroll is on and at bottom', () => { + const props = createDefaultProps({ + autoScrollAiMode: true, + setAutoScrollAiMode: vi.fn(), + }); + + render(); + + const button = screen.getByTitle('Auto-scroll ON (click to unpin)'); + // Active state uses accent background + expect(button).toHaveStyle({ backgroundColor: defaultTheme.colors.accent }); + expect(button).toHaveStyle({ color: defaultTheme.colors.accentForeground }); + }); + + it('button does not render when autoScrollAiMode is false and at bottom', () => { + const props = createDefaultProps({ + autoScrollAiMode: false, + setAutoScrollAiMode: vi.fn(), + }); + + render(); + + // When not active and at bottom, button is hidden + expect(screen.queryByTitle(/Auto-scroll|Scroll to bottom/)).not.toBeInTheDocument(); + }); + }); + + describe('auto-scroll pause and resume behavior', () => { + it('button changes to "Scroll to bottom" when user scrolls away from bottom', async () => { + const setAutoScrollAiMode = vi.fn(); + const logs: LogEntry[] = Array.from({ length: 20 }, (_, i) => + createLogEntry({ + id: `log-${i}`, + text: `Message ${i}`, + source: i % 2 === 0 ? 'user' : 'stdout', + }) + ); + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + const props = createDefaultProps({ + session, + autoScrollAiMode: true, + setAutoScrollAiMode, + }); + + const { container } = render(); + + const scrollContainer = container.querySelector('.overflow-y-auto') as HTMLElement; + + // Simulate scroll away from bottom (more than 50px from bottom) + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 2000, configurable: true }); + Object.defineProperty(scrollContainer, 'scrollTop', { value: 500, configurable: true }); + Object.defineProperty(scrollContainer, 'clientHeight', { value: 400, configurable: true }); + + fireEvent.scroll(scrollContainer); + + // Wait for throttle + await act(async () => { + vi.advanceTimersByTime(50); + }); + + // After scrolling up, the button should show "Scroll to bottom" (paused internally) + const button = screen.getByTitle(/Scroll to bottom/); + expect(button).toBeInTheDocument(); + }); + + it('clicking button when scrolled away snaps to bottom and re-pins', async () => { + const setAutoScrollAiMode = vi.fn(); + const logs: LogEntry[] = Array.from({ length: 20 }, (_, i) => + createLogEntry({ + id: `log-${i}`, + text: `Message ${i}`, + source: i % 2 === 0 ? 'user' : 'stdout', + }) + ); + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + const props = createDefaultProps({ + session, + autoScrollAiMode: true, + setAutoScrollAiMode, + }); + + const { container } = render(); + + const scrollContainer = container.querySelector('.overflow-y-auto') as HTMLElement; + const scrollToSpy = vi.fn(); + scrollContainer.scrollTo = scrollToSpy; + + // Simulate scroll away from bottom to trigger pause + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 2000, configurable: true }); + Object.defineProperty(scrollContainer, 'scrollTop', { value: 500, configurable: true }); + Object.defineProperty(scrollContainer, 'clientHeight', { value: 400, configurable: true }); + + fireEvent.scroll(scrollContainer); + + await act(async () => { + vi.advanceTimersByTime(50); + }); + + // Should now show "Scroll to bottom" state + const scrollButton = screen.getByTitle(/Scroll to bottom/); + expect(scrollButton).toBeInTheDocument(); + + // Click to resume + await act(async () => { + fireEvent.click(scrollButton); + }); + + // Should have called scrollTo to snap to bottom + expect(scrollToSpy).toHaveBeenCalledWith({ + top: 2000, // scrollHeight + behavior: 'smooth', + }); + + // Button should now show active state (re-pinned) + expect(screen.getByTitle('Auto-scroll ON (click to unpin)')).toBeInTheDocument(); + }); + }); + + describe('props threading', () => { + it('TerminalOutput accepts and uses autoScrollAiMode and setAutoScrollAiMode props', () => { + // This tests that the props interface is properly defined and used + const setAutoScrollAiMode = vi.fn(); + const props = createDefaultProps({ + autoScrollAiMode: true, + setAutoScrollAiMode, + }); + + // Should render without errors and show the auto-scroll button + const { container } = render(); + expect(container).toBeTruthy(); + expect(screen.getByTitle('Auto-scroll ON (click to unpin)')).toBeInTheDocument(); + }); + + it('TerminalOutput renders correctly without auto-scroll props (backward compatible)', () => { + // When auto-scroll props are not provided, component should render normally + const props = createDefaultProps(); + + const { container } = render(); + expect(container).toBeTruthy(); + // No auto-scroll button should be rendered + expect(screen.queryByTitle(/Auto-scroll/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/renderer/constants/themes.test.ts b/src/__tests__/renderer/constants/themes.test.ts index 5c29884db..6cf1f4e0b 100644 --- a/src/__tests__/renderer/constants/themes.test.ts +++ b/src/__tests__/renderer/constants/themes.test.ts @@ -11,20 +11,46 @@ import { isValidThemeId } from '../../../shared/theme-types'; */ // Required color properties that every theme must have +// Must match ThemeColors interface in src/shared/theme-types.ts exactly const REQUIRED_COLORS: (keyof ThemeColors)[] = [ + // Core backgrounds 'bgMain', 'bgSidebar', 'bgActivity', 'border', + // Typography 'textMain', 'textDim', + // Accent 'accent', 'accentDim', 'accentText', 'accentForeground', + // Status colors 'success', 'warning', 'error', + 'info', + // Status foregrounds (text ON status backgrounds) + 'successForeground', + 'warningForeground', + 'errorForeground', + // Status dim backgrounds (subtle badges/tags) + 'successDim', + 'warningDim', + 'errorDim', + 'infoDim', + // Git diff colors + 'diffAddition', + 'diffAdditionBg', + 'diffDeletion', + 'diffDeletionBg', + // Overlay and interactive states + 'overlay', + 'overlayHeavy', + 'hoverBg', + 'activeBg', + 'shadow', ]; // Hex color regex diff --git a/src/__tests__/renderer/utils/markdownConfig.test.ts b/src/__tests__/renderer/utils/markdownConfig.test.ts index de3738a84..d08c09a35 100644 --- a/src/__tests__/renderer/utils/markdownConfig.test.ts +++ b/src/__tests__/renderer/utils/markdownConfig.test.ts @@ -48,6 +48,10 @@ const mockTheme: Theme = { bgSidebar: '#2a2a2a', bgActivity: '#333333', border: '#444444', + diffAddition: '#50fa7b', + diffAdditionBg: 'rgba(80, 250, 123, 0.15)', + diffDeletion: '#ff5555', + diffDeletionBg: 'rgba(255, 85, 85, 0.15)', }, }; @@ -565,24 +569,22 @@ describe('generateDiffViewStyles', () => { const css = generateDiffViewStyles(mockTheme); expect(css).toContain('.diff-gutter-insert'); expect(css).toContain('.diff-code-insert'); - expect(css).toContain('rgba(34, 197, 94, 0.1)'); - expect(css).toContain('rgba(34, 197, 94, 0.15)'); + expect(css).toContain(mockTheme.colors.diffAdditionBg); }); it('should include delete (red) color styling', () => { const css = generateDiffViewStyles(mockTheme); expect(css).toContain('.diff-gutter-delete'); expect(css).toContain('.diff-code-delete'); - expect(css).toContain('rgba(239, 68, 68, 0.1)'); - expect(css).toContain('rgba(239, 68, 68, 0.15)'); + expect(css).toContain(mockTheme.colors.diffDeletionBg); }); it('should include edit highlight styles within insert/delete', () => { const css = generateDiffViewStyles(mockTheme); expect(css).toContain('.diff-code-insert .diff-code-edit'); - expect(css).toContain('rgba(34, 197, 94, 0.3)'); + expect(css).toContain(`${mockTheme.colors.diffAddition}40`); expect(css).toContain('.diff-code-delete .diff-code-edit'); - expect(css).toContain('rgba(239, 68, 68, 0.3)'); + expect(css).toContain(`${mockTheme.colors.diffDeletion}40`); }); it('should include hunk header styles', () => { diff --git a/src/__tests__/web/utils/cssCustomProperties.test.ts b/src/__tests__/web/utils/cssCustomProperties.test.ts index 8832acd59..b66d23285 100644 --- a/src/__tests__/web/utils/cssCustomProperties.test.ts +++ b/src/__tests__/web/utils/cssCustomProperties.test.ts @@ -40,6 +40,23 @@ function createMockTheme(overrides?: Partial): Theme { success: '#50fa7b', warning: '#ffb86c', error: '#ff5555', + info: '#8be9fd', + successForeground: '#282a36', + warningForeground: '#282a36', + errorForeground: '#282a36', + successDim: 'rgba(80, 250, 123, 0.15)', + warningDim: 'rgba(255, 184, 108, 0.15)', + errorDim: 'rgba(255, 85, 85, 0.15)', + infoDim: 'rgba(139, 233, 253, 0.15)', + diffAddition: '#50fa7b', + diffAdditionBg: 'rgba(80, 250, 123, 0.15)', + diffDeletion: '#ff5555', + diffDeletionBg: 'rgba(255, 85, 85, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, ...overrides, }; @@ -65,14 +82,31 @@ function createLightTheme(): Theme { success: '#1a7f37', warning: '#9a6700', error: '#cf222e', + info: '#0969da', + successForeground: '#ffffff', + warningForeground: '#ffffff', + errorForeground: '#ffffff', + successDim: 'rgba(26, 127, 55, 0.1)', + warningDim: 'rgba(154, 103, 0, 0.1)', + errorDim: 'rgba(207, 34, 46, 0.1)', + infoDim: 'rgba(9, 105, 218, 0.1)', + diffAddition: '#1a7f37', + diffAdditionBg: 'rgba(26, 127, 55, 0.1)', + diffDeletion: '#cf222e', + diffDeletionBg: 'rgba(207, 34, 46, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }; } describe('cssCustomProperties', () => { describe('THEME_CSS_PROPERTIES constant', () => { - it('should contain all 14 theme CSS properties', () => { - expect(THEME_CSS_PROPERTIES).toHaveLength(14); + it('should contain all 31 theme CSS properties', () => { + expect(THEME_CSS_PROPERTIES).toHaveLength(31); }); it('should include all color properties', () => { @@ -146,11 +180,11 @@ describe('cssCustomProperties', () => { expect(properties['--maestro-mode']).toBe('vibe'); }); - it('should return all 14 properties', () => { + it('should return all 31 properties', () => { const theme = createMockTheme(); const properties = generateCSSProperties(theme); - expect(Object.keys(properties)).toHaveLength(14); + expect(Object.keys(properties)).toHaveLength(31); }); it('should handle themes with rgba colors', () => { @@ -437,7 +471,7 @@ describe('cssCustomProperties', () => { expect(element.style.getPropertyValue('--maestro-mode')).toBe('dark'); }); - it('should set all 13 properties', () => { + it('should set all 31 properties', () => { const theme = createMockTheme(); setElementCSSProperties(element, theme); @@ -447,7 +481,7 @@ describe('cssCustomProperties', () => { count++; } }); - expect(count).toBe(14); + expect(count).toBe(31); }); it('should update properties when called again with different theme', () => { @@ -751,8 +785,8 @@ describe('cssCustomProperties', () => { // Should be valid CSS that can be parsed expect(css).toMatch(/^:root \{[\s\S]+\}$/); - // Should contain all properties (14 = 13 colors + accentForeground + mode) - expect((css.match(/--maestro-/g) || []).length).toBe(14); + // Should contain all properties (31 = 30 color tokens + mode) + expect((css.match(/--maestro-/g) || []).length).toBe(31); }); it('should support cssVar in style objects pattern', () => { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ce3004ad0..b6b5b7a94 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -547,6 +547,10 @@ function MaestroConsoleInner() { // File tab refresh settings fileTabAutoRefreshEnabled, + // Auto-scroll settings + autoScrollAiMode, + setAutoScrollAiMode, + // Windows warning suppression suppressWindowsWarning, setSuppressWindowsWarning, @@ -10832,6 +10836,10 @@ You are taking over this conversation. Based on the context above, provide a bri // Session bookmark toggle toggleBookmark, + + // Auto-scroll AI mode toggle + autoScrollAiMode, + setAutoScrollAiMode, }; // Update flat file list when active session's tree, expanded folders, filter, or hidden files setting changes @@ -11209,6 +11217,8 @@ You are taking over this conversation. Based on the context above, provide a bri filePreviewLoading, markdownEditMode, chatRawTextMode, + autoScrollAiMode, + setAutoScrollAiMode, shortcuts, rightPanelOpen, maxOutputLines, @@ -11942,6 +11952,8 @@ You are taking over this conversation. Based on the context above, provide a bri onOpenMarketplace={handleOpenMarketplace} onOpenSymphony={() => setSymphonyModalOpen(true)} onOpenDirectorNotes={() => setDirectorNotesOpen(true)} + autoScrollAiMode={autoScrollAiMode} + setAutoScrollAiMode={setAutoScrollAiMode} tabSwitcherOpen={tabSwitcherOpen} onCloseTabSwitcher={handleCloseTabSwitcher} onTabSelect={handleUtilityTabSelect} @@ -12704,6 +12716,8 @@ You are taking over this conversation. Based on the context above, provide a bri setCrashReportingEnabled={setCrashReportingEnabled} customAICommands={customAICommands} setCustomAICommands={setCustomAICommands} + autoScrollAiMode={autoScrollAiMode} + setAutoScrollAiMode={setAutoScrollAiMode} initialTab={settingsTab} hasNoAgents={hasNoAgents} onThemeImportError={(msg) => setFlashNotification(msg)} @@ -12827,7 +12841,7 @@ You are taking over this conversation. Based on the context above, provide a bri style={{ backgroundColor: theme.colors.accent, color: theme.colors.accentForeground, - textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)', + textShadow: `0 1px 2px ${theme.colors.shadow}`, }} > {successFlashNotification} diff --git a/src/renderer/components/AICommandsPanel.tsx b/src/renderer/components/AICommandsPanel.tsx index 55ff97343..9fa9b3606 100644 --- a/src/renderer/components/AICommandsPanel.tsx +++ b/src/renderer/components/AICommandsPanel.tsx @@ -317,7 +317,7 @@ export function AICommandsPanel({ className="flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium transition-all disabled:opacity-50" style={{ backgroundColor: theme.colors.success, - color: '#000000', + color: theme.colors.successForeground, }} > @@ -365,7 +365,7 @@ export function AICommandsPanel({ className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all" style={{ backgroundColor: theme.colors.success, - color: '#000000', + color: theme.colors.successForeground, }} > diff --git a/src/renderer/components/AchievementCard.tsx b/src/renderer/components/AchievementCard.tsx index 34ce18c22..53f4d3f16 100644 --- a/src/renderer/components/AchievementCard.tsx +++ b/src/renderer/components/AchievementCard.tsx @@ -208,7 +208,7 @@ function BadgeTooltip({ style={{ backgroundColor: theme.colors.bgSidebar, border: `1px solid ${theme.colors.border}`, - boxShadow: `0 4px 20px rgba(0,0,0,0.3)`, + boxShadow: `0 4px 20px ${theme.colors.shadow}`, ...getPositionStyles(), }} onClick={(e) => e.stopPropagation()} @@ -1059,7 +1059,7 @@ export function AchievementCard({ borderRadius: '50%', background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', color: '#000', - boxShadow: '0 2px 4px rgba(0,0,0,0.3)', + boxShadow: `0 2px 4px ${theme.colors.shadow}`, }} > {currentLevel} diff --git a/src/renderer/components/AgentCreationDialog.tsx b/src/renderer/components/AgentCreationDialog.tsx index 67d4877bc..333162a71 100644 --- a/src/renderer/components/AgentCreationDialog.tsx +++ b/src/renderer/components/AgentCreationDialog.tsx @@ -282,7 +282,7 @@ export function AgentCreationDialog({ const modalContent = (
{ if (e.target === e.currentTarget) { onSubmit(value); diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index bfef20751..c3f904efc 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -1138,9 +1138,7 @@ export function AgentSessionsBrowser({ msg.type === 'user' ? theme.colors.accent : theme.colors.bgActivity, color: msg.type === 'user' - ? theme.mode === 'light' - ? '#fff' - : '#000' + ? theme.colors.accentForeground : theme.colors.textMain, }} > @@ -1152,9 +1150,7 @@ export function AgentSessionsBrowser({ style={{ color: msg.type === 'user' - ? theme.mode === 'light' - ? '#fff' - : '#000' + ? theme.colors.accentForeground : theme.colors.textDim, }} > diff --git a/src/renderer/components/AgentSessionsModal.tsx b/src/renderer/components/AgentSessionsModal.tsx index bafafc56e..89a5e17dd 100644 --- a/src/renderer/components/AgentSessionsModal.tsx +++ b/src/renderer/components/AgentSessionsModal.tsx @@ -550,9 +550,7 @@ export function AgentSessionsModal({ msg.type === 'user' ? theme.colors.accent : theme.colors.bgMain, color: msg.type === 'user' - ? theme.mode === 'light' - ? '#fff' - : '#000' + ? theme.colors.accentForeground : theme.colors.textMain, }} > @@ -564,9 +562,7 @@ export function AgentSessionsModal({ style={{ color: msg.type === 'user' - ? theme.mode === 'light' - ? '#fff' - : '#000' + ? theme.colors.accentForeground : theme.colors.textDim, }} > @@ -611,7 +607,7 @@ export function AgentSessionsModal({ className="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-opacity-10 transition-colors group" style={{ backgroundColor: i === selectedIndex ? theme.colors.accent : 'transparent', - color: theme.colors.textMain, + color: i === selectedIndex ? theme.colors.accentForeground : theme.colors.textMain, }} > {/* Star button */} diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 6f28977fd..c1e5a956f 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -849,6 +849,10 @@ export interface AppUtilityModalsProps { // Director's Notes onOpenDirectorNotes?: () => void; + // Auto-scroll + autoScrollAiMode?: boolean; + setAutoScrollAiMode?: (value: boolean) => void; + // LightboxModal lightboxImage: string | null; lightboxImages: string[]; @@ -1046,6 +1050,9 @@ export function AppUtilityModals({ onOpenSymphony, // Director's Notes onOpenDirectorNotes, + // Auto-scroll + autoScrollAiMode, + setAutoScrollAiMode, // LightboxModal lightboxImage, lightboxImages, @@ -1201,6 +1208,8 @@ export function AppUtilityModals({ onOpenLastDocumentGraph={onOpenLastDocumentGraph} onOpenSymphony={onOpenSymphony} onOpenDirectorNotes={onOpenDirectorNotes} + autoScrollAiMode={autoScrollAiMode} + setAutoScrollAiMode={setAutoScrollAiMode} /> )} @@ -1984,6 +1993,9 @@ export interface AppModalsProps { onOpenSymphony?: () => void; // Director's Notes onOpenDirectorNotes?: () => void; + // Auto-scroll + autoScrollAiMode?: boolean; + setAutoScrollAiMode?: (value: boolean) => void; tabSwitcherOpen: boolean; onCloseTabSwitcher: () => void; onTabSelect: (tabId: string) => void; @@ -2305,6 +2317,9 @@ export function AppModals(props: AppModalsProps) { onOpenSymphony, // Director's Notes onOpenDirectorNotes, + // Auto-scroll + autoScrollAiMode, + setAutoScrollAiMode, tabSwitcherOpen, onCloseTabSwitcher, onTabSelect, @@ -2612,6 +2627,8 @@ export function AppModals(props: AppModalsProps) { onOpenMarketplace={onOpenMarketplace} onOpenSymphony={onOpenSymphony} onOpenDirectorNotes={onOpenDirectorNotes} + autoScrollAiMode={autoScrollAiMode} + setAutoScrollAiMode={setAutoScrollAiMode} tabSwitcherOpen={tabSwitcherOpen} onCloseTabSwitcher={onCloseTabSwitcher} onTabSelect={onTabSelect} diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index aac413ffd..b96433ac7 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -431,7 +431,7 @@ function ImagePreview({ className="absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" style={{ backgroundColor: theme.colors.error, - color: 'white', + color: theme.colors.errorForeground, }} title="Remove image" > @@ -440,8 +440,8 @@ function ImagePreview({
{filename} @@ -1707,7 +1707,7 @@ const AutoRunInner = forwardRef(function AutoRunInn className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors font-semibold ${isStopping ? 'cursor-not-allowed' : ''}`} style={{ backgroundColor: isStopping ? theme.colors.warning : theme.colors.error, - color: isStopping ? theme.colors.bgMain : 'white', + color: isStopping ? theme.colors.warningForeground : theme.colors.errorForeground, border: `1px solid ${isStopping ? theme.colors.warning : theme.colors.error}`, pointerEvents: isStopping ? 'none' : 'auto', }} @@ -1857,7 +1857,7 @@ const AutoRunInner = forwardRef(function AutoRunInn className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors hover:opacity-80" style={{ backgroundColor: theme.colors.error, - color: 'white', + color: theme.colors.errorForeground, }} title="Stop Auto Run completely" > diff --git a/src/renderer/components/AutoRunDocumentSelector.tsx b/src/renderer/components/AutoRunDocumentSelector.tsx index 41dfe2a10..8e1080776 100644 --- a/src/renderer/components/AutoRunDocumentSelector.tsx +++ b/src/renderer/components/AutoRunDocumentSelector.tsx @@ -225,7 +225,7 @@ export function AutoRunDocumentSelector({ : theme.colors.accentDim : 'transparent', color: - taskPct !== null ? (taskPct === 100 ? '#000' : theme.colors.textDim) : 'transparent', + taskPct !== null ? (taskPct === 100 ? theme.colors.successForeground : theme.colors.textDim) : 'transparent', }} > {taskPct !== null ? `${taskPct}%` : ''} @@ -259,7 +259,7 @@ export function AutoRunDocumentSelector({ selectedTaskPercentage === 100 ? theme.colors.success : theme.colors.accentDim, - color: selectedTaskPercentage === 100 ? '#000' : theme.colors.textDim, + color: selectedTaskPercentage === 100 ? theme.colors.successForeground : theme.colors.textDim, }} > {selectedTaskPercentage}% @@ -325,7 +325,7 @@ export function AutoRunDocumentSelector({ color: taskPct !== null ? taskPct === 100 - ? '#000' + ? theme.colors.successForeground : theme.colors.textDim : 'transparent', }} diff --git a/src/renderer/components/AutoRunExpandedModal.tsx b/src/renderer/components/AutoRunExpandedModal.tsx index 03139e19d..c6d453bd2 100644 --- a/src/renderer/components/AutoRunExpandedModal.tsx +++ b/src/renderer/components/AutoRunExpandedModal.tsx @@ -230,7 +230,7 @@ export function AutoRunExpandedModal({ return createPortal(
{ if (e.target === e.currentTarget) { handleClose(); @@ -366,7 +366,7 @@ export function AutoRunExpandedModal({ className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors font-semibold ${isStopping ? 'cursor-not-allowed' : ''}`} style={{ backgroundColor: isStopping ? theme.colors.warning : theme.colors.error, - color: isStopping ? theme.colors.bgMain : 'white', + color: isStopping ? theme.colors.bgMain : theme.colors.errorForeground, border: `1px solid ${isStopping ? theme.colors.warning : theme.colors.error}`, pointerEvents: isStopping ? 'none' : 'auto', }} diff --git a/src/renderer/components/AutoRunnerHelpModal.tsx b/src/renderer/components/AutoRunnerHelpModal.tsx index 473ff6d21..a7a19cba1 100644 --- a/src/renderer/components/AutoRunnerHelpModal.tsx +++ b/src/renderer/components/AutoRunnerHelpModal.tsx @@ -41,7 +41,7 @@ export function AutoRunnerHelpModal({ theme, onClose }: AutoRunnerHelpModalProps className="px-4 py-2 rounded text-sm font-medium transition-colors hover:opacity-90" style={{ backgroundColor: theme.colors.accent, - color: 'white', + color: theme.colors.accentForeground, }} > Got it diff --git a/src/renderer/components/ContextWarningSash.tsx b/src/renderer/components/ContextWarningSash.tsx index 5c7946d6a..8f56b7f86 100644 --- a/src/renderer/components/ContextWarningSash.tsx +++ b/src/renderer/components/ContextWarningSash.tsx @@ -24,7 +24,7 @@ export interface ContextWarningSashProps { * - Dismiss button that hides the warning until usage increases 10%+ or crosses threshold */ export function ContextWarningSash({ - theme: _theme, + theme, contextUsage, yellowThreshold, redThreshold, @@ -77,19 +77,12 @@ export function ContextWarningSash({ const isRed = warningLevel === 'red'; - // Color values from spec - const backgroundColor = isRed - ? 'rgba(239, 68, 68, 0.15)' // red-500 with low opacity - : 'rgba(234, 179, 8, 0.15)'; // yellow-500 with low opacity - - const borderColor = isRed ? 'rgba(239, 68, 68, 0.5)' : 'rgba(234, 179, 8, 0.5)'; - - const textColor = isRed - ? '#fca5a5' // red-300 - : '#fde047'; // yellow-300 - - const iconColor = isRed ? '#ef4444' : '#eab308'; - const buttonBgColor = isRed ? '#ef4444' : '#eab308'; + const backgroundColor = isRed ? theme.colors.errorDim : theme.colors.warningDim; + const borderColor = isRed ? theme.colors.error : theme.colors.warning; + const textColor = isRed ? theme.colors.error : theme.colors.warning; + const iconColor = isRed ? theme.colors.error : theme.colors.warning; + const buttonBgColor = isRed ? theme.colors.error : theme.colors.warning; + const buttonTextColor = isRed ? theme.colors.errorForeground : theme.colors.warningForeground; return (
Compact & Continue diff --git a/src/renderer/components/CreatePRModal.tsx b/src/renderer/components/CreatePRModal.tsx index 8aa19be12..9e4e40c1c 100644 --- a/src/renderer/components/CreatePRModal.tsx +++ b/src/renderer/components/CreatePRModal.tsx @@ -206,7 +206,7 @@ export function CreatePRModal({ return (
{/* Backdrop */} -
+
{/* Modal */}
{/* Backdrop */} -
+
{/* Modal */}
{query - ? highlightMatches(row[colIdx] ?? '', query, theme.colors.accent) + ? highlightMatches(row[colIdx] ?? '', query, theme.colors.accent, theme.colors.accentForeground) : (row[colIdx] ?? '')} ))} diff --git a/src/renderer/components/CustomThemeBuilder.tsx b/src/renderer/components/CustomThemeBuilder.tsx index 40c8dc15d..a505e4daa 100644 --- a/src/renderer/components/CustomThemeBuilder.tsx +++ b/src/renderer/components/CustomThemeBuilder.tsx @@ -29,23 +29,83 @@ interface CustomThemeBuilderProps { onImportSuccess?: (message: string) => void; } -// Color picker labels with descriptions -const COLOR_CONFIG: { key: keyof ThemeColors; label: string; description: string }[] = [ - { key: 'bgMain', label: 'Main Background', description: 'Primary content area' }, - { key: 'bgSidebar', label: 'Sidebar Background', description: 'Left & right panels' }, - { key: 'bgActivity', label: 'Activity Background', description: 'Hover, active states' }, - { key: 'border', label: 'Border', description: 'Dividers & outlines' }, - { key: 'textMain', label: 'Main Text', description: 'Primary text color' }, - { key: 'textDim', label: 'Dimmed Text', description: 'Secondary text' }, - { key: 'accent', label: 'Accent', description: 'Highlights, links' }, - { key: 'accentDim', label: 'Accent Dim', description: 'Accent with transparency' }, - { key: 'accentText', label: 'Accent Text', description: 'Text in accent contexts' }, - { key: 'accentForeground', label: 'Accent Foreground', description: 'Text ON accent' }, - { key: 'success', label: 'Success', description: 'Green states' }, - { key: 'warning', label: 'Warning', description: 'Yellow/orange states' }, - { key: 'error', label: 'Error', description: 'Red states' }, +// Color picker sections with labels and descriptions +const COLOR_SECTIONS: { title: string; items: { key: keyof ThemeColors; label: string; description: string }[] }[] = [ + { + title: 'Backgrounds', + items: [ + { key: 'bgMain', label: 'Main Background', description: 'Primary content area' }, + { key: 'bgSidebar', label: 'Sidebar Background', description: 'Left & right panels' }, + { key: 'bgActivity', label: 'Activity Background', description: 'Hover, active states' }, + { key: 'border', label: 'Border', description: 'Dividers & outlines' }, + ], + }, + { + title: 'Typography', + items: [ + { key: 'textMain', label: 'Main Text', description: 'Primary text color' }, + { key: 'textDim', label: 'Dimmed Text', description: 'Secondary text' }, + ], + }, + { + title: 'Accent', + items: [ + { key: 'accent', label: 'Accent', description: 'Highlights, links' }, + { key: 'accentDim', label: 'Accent Dim', description: 'Accent with transparency' }, + { key: 'accentText', label: 'Accent Text', description: 'Text in accent contexts' }, + { key: 'accentForeground', label: 'Accent Foreground', description: 'Text ON accent bg' }, + ], + }, + { + title: 'Status', + items: [ + { key: 'success', label: 'Success', description: 'Green states' }, + { key: 'warning', label: 'Warning', description: 'Yellow/orange states' }, + { key: 'error', label: 'Error', description: 'Red states' }, + { key: 'info', label: 'Info', description: 'Blue/informational states' }, + ], + }, + { + title: 'Status Foregrounds', + items: [ + { key: 'successForeground', label: 'Success Foreground', description: 'Text ON success bg' }, + { key: 'warningForeground', label: 'Warning Foreground', description: 'Text ON warning bg' }, + { key: 'errorForeground', label: 'Error Foreground', description: 'Text ON error bg' }, + ], + }, + { + title: 'Status Dim Backgrounds', + items: [ + { key: 'successDim', label: 'Success Dim', description: 'Subtle success badges' }, + { key: 'warningDim', label: 'Warning Dim', description: 'Subtle warning badges' }, + { key: 'errorDim', label: 'Error Dim', description: 'Subtle error badges' }, + { key: 'infoDim', label: 'Info Dim', description: 'Subtle info badges' }, + ], + }, + { + title: 'Git Diff', + items: [ + { key: 'diffAddition', label: 'Diff Addition', description: 'Added lines color' }, + { key: 'diffAdditionBg', label: 'Diff Addition Bg', description: 'Added lines background' }, + { key: 'diffDeletion', label: 'Diff Deletion', description: 'Deleted lines color' }, + { key: 'diffDeletionBg', label: 'Diff Deletion Bg', description: 'Deleted lines background' }, + ], + }, + { + title: 'Overlays & Interactive', + items: [ + { key: 'overlay', label: 'Overlay', description: 'Modal backdrop' }, + { key: 'overlayHeavy', label: 'Overlay Heavy', description: 'Wizard/fullscreen backdrop' }, + { key: 'hoverBg', label: 'Hover Background', description: 'Subtle hover state' }, + { key: 'activeBg', label: 'Active Background', description: 'Selected/active state' }, + { key: 'shadow', label: 'Shadow', description: 'Elevation shadow color' }, + ], + }, ]; +// Flat list of all color config items (used for import validation and iteration) +const COLOR_CONFIG = COLOR_SECTIONS.flatMap((section) => section.items); + // Mini UI Preview component function MiniUIPreview({ colors }: { colors: ThemeColors }) { return ( @@ -570,16 +630,26 @@ export function CustomThemeBuilder({ borderColor: theme.colors.border, }} > - {COLOR_CONFIG.map(({ key, label, description }) => ( - + {COLOR_SECTIONS.map((section) => ( +
+
+ {section.title} +
+ {section.items.map(({ key, label, description }) => ( + + ))} +
))}
diff --git a/src/renderer/components/DeleteAgentConfirmModal.tsx b/src/renderer/components/DeleteAgentConfirmModal.tsx index 35ceb5e48..4ffad8a43 100644 --- a/src/renderer/components/DeleteAgentConfirmModal.tsx +++ b/src/renderer/components/DeleteAgentConfirmModal.tsx @@ -75,7 +75,7 @@ export function DeleteAgentConfirmModal({ className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap" style={{ backgroundColor: `${theme.colors.error}99`, - color: '#ffffff', + color: theme.colors.errorForeground, }} > Agent Only @@ -90,7 +90,7 @@ export function DeleteAgentConfirmModal({ }`} style={{ backgroundColor: theme.colors.error, - color: '#ffffff', + color: theme.colors.errorForeground, }} > Agent + Working Directory diff --git a/src/renderer/components/DeleteWorktreeModal.tsx b/src/renderer/components/DeleteWorktreeModal.tsx index df0002190..b7a56aa51 100644 --- a/src/renderer/components/DeleteWorktreeModal.tsx +++ b/src/renderer/components/DeleteWorktreeModal.tsx @@ -67,7 +67,7 @@ export function DeleteWorktreeModal({ className="px-3 py-1.5 rounded transition-colors outline-none flex items-center justify-center gap-1.5 text-xs whitespace-nowrap ml-auto" style={{ backgroundColor: theme.colors.error, - color: '#ffffff', + color: theme.colors.errorForeground, opacity: 0.7, }} > @@ -106,7 +106,7 @@ export function DeleteWorktreeModal({ className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap" style={{ backgroundColor: `${theme.colors.error}99`, - color: '#ffffff', + color: theme.colors.errorForeground, }} > Remove @@ -123,7 +123,7 @@ export function DeleteWorktreeModal({ className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap" style={{ backgroundColor: theme.colors.error, - color: '#ffffff', + color: theme.colors.errorForeground, }} > Remove and Delete diff --git a/src/renderer/components/DocumentGraph/DocumentNode.tsx b/src/renderer/components/DocumentGraph/DocumentNode.tsx index fa9b42cba..b8a51aecc 100644 --- a/src/renderer/components/DocumentGraph/DocumentNode.tsx +++ b/src/renderer/components/DocumentGraph/DocumentNode.tsx @@ -182,13 +182,13 @@ export const DocumentNode = memo(function DocumentNode({ data, selected }: Docum [] ); - // Large file indicator style (blue info color) + // Large file indicator style (info color) const largeFileIconStyle = useMemo( () => ({ - color: '#3b82f6', // Blue info color + color: theme.colors.info, flexShrink: 0, }), - [] + [theme.colors.info] ); // Truncate title if too long diff --git a/src/renderer/components/DocumentsPanel.tsx b/src/renderer/components/DocumentsPanel.tsx index 9bac8e178..2f14fe73d 100644 --- a/src/renderer/components/DocumentsPanel.tsx +++ b/src/renderer/components/DocumentsPanel.tsx @@ -412,7 +412,8 @@ function DocumentSelectorModal({ return (
diff --git a/src/renderer/components/ExecutionQueueBrowser.tsx b/src/renderer/components/ExecutionQueueBrowser.tsx index fc695a908..20373f95c 100644 --- a/src/renderer/components/ExecutionQueueBrowser.tsx +++ b/src/renderer/components/ExecutionQueueBrowser.tsx @@ -121,7 +121,7 @@ export function ExecutionQueueBrowser({ return (
{/* Backdrop */} -
+
{/* Modal */}
0) { matchElements[0].style.backgroundColor = theme.colors.accent; - matchElements[0].style.color = '#fff'; + matchElements[0].style.color = theme.colors.accentForeground; matchElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); } @@ -1328,7 +1328,15 @@ export const FilePreview = React.memo( }); matchElementsRef.current = []; }; - }, [searchQuery, file?.content, isMarkdown, isImage, isCsv, theme.colors.accent]); + }, [ + searchQuery, + file?.content, + isMarkdown, + isImage, + isCsv, + theme.colors.accent, + theme.colors.accentForeground, + ]); // Search matches in markdown preview mode - use CSS Custom Highlight API useEffect(() => { @@ -1502,7 +1510,7 @@ export const FilePreview = React.memo( // Highlight new current match and scroll to it if (matches[nextIndex]) { matches[nextIndex].style.backgroundColor = theme.colors.accent; - matches[nextIndex].style.color = '#fff'; + matches[nextIndex].style.color = theme.colors.accentForeground; matches[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); } } @@ -1528,7 +1536,7 @@ export const FilePreview = React.memo( // Highlight new current match and scroll to it if (matches[prevIndex]) { matches[prevIndex].style.backgroundColor = theme.colors.accent; - matches[prevIndex].style.color = '#fff'; + matches[prevIndex].style.color = theme.colors.accentForeground; matches[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); } } @@ -2101,7 +2109,7 @@ export const FilePreview = React.memo( className="px-2 py-1 text-xs font-medium rounded hover:opacity-80 transition-opacity" style={{ backgroundColor: theme.colors.accent, - color: theme.colors.accentForeground ?? '#000', + color: theme.colors.accentForeground, }} > Reload @@ -2561,7 +2569,7 @@ export const FilePreview = React.memo( style={{ backgroundColor: theme.colors.accent, color: theme.colors.accentForeground, - textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)', + textShadow: `0 1px 2px ${theme.colors.shadow}`, }} > {copyNotificationMessage} diff --git a/src/renderer/components/GitDiffViewer.tsx b/src/renderer/components/GitDiffViewer.tsx index beb5e6a4a..3308ad1e0 100644 --- a/src/renderer/components/GitDiffViewer.tsx +++ b/src/renderer/components/GitDiffViewer.tsx @@ -219,13 +219,13 @@ export const GitDiffViewer = memo(function GitDiffViewer({ ) : ( <> {fileStats.additions > 0 && ( - + {fileStats.additions} )} {fileStats.deletions > 0 && ( - + {fileStats.deletions} @@ -311,11 +311,11 @@ export const GitDiffViewer = memo(function GitDiffViewer({ ) : (
- + {stats.additions} additions - + {stats.deletions} deletions diff --git a/src/renderer/components/GitLogViewer.tsx b/src/renderer/components/GitLogViewer.tsx index 3d252a93c..2e835b2ce 100644 --- a/src/renderer/components/GitLogViewer.tsx +++ b/src/renderer/components/GitLogViewer.tsx @@ -388,13 +388,13 @@ export const GitLogViewer = memo(function GitLogViewer({ backgroundColor: isTag ? 'rgba(234, 179, 8, 0.2)' : isBranch - ? 'rgba(34, 197, 94, 0.2)' - : 'rgba(59, 130, 246, 0.2)', + ? theme.colors.diffAdditionBg + : theme.colors.infoDim, color: isTag ? 'rgb(234, 179, 8)' : isBranch - ? 'rgb(34, 197, 94)' - : 'rgb(59, 130, 246)', + ? theme.colors.diffAddition + : theme.colors.info, }} > {isTag ? ( @@ -429,10 +429,10 @@ export const GitLogViewer = memo(function GitLogViewer({ {((entry.additions ?? 0) > 0 || (entry.deletions ?? 0) > 0) && ( {(entry.additions ?? 0) > 0 && ( - +{entry.additions} + +{entry.additions} )} {(entry.deletions ?? 0) > 0 && ( - -{entry.deletions} + -{entry.deletions} )} )} diff --git a/src/renderer/components/GroupChatInput.tsx b/src/renderer/components/GroupChatInput.tsx index e9e50a40e..afd5877ef 100644 --- a/src/renderer/components/GroupChatInput.tsx +++ b/src/renderer/components/GroupChatInput.tsx @@ -457,7 +457,7 @@ export const GroupChatInput = React.memo(function GroupChatInput({ className="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity" style={{ backgroundColor: theme.colors.error, - color: '#ffffff', + color: theme.colors.errorForeground, }} > × @@ -573,7 +573,11 @@ export const GroupChatInput = React.memo(function GroupChatInput({ ? theme.colors.warning : theme.colors.accent : theme.colors.border, - color: message.trim() ? '#ffffff' : theme.colors.textDim, + color: message.trim() + ? isBusy + ? theme.colors.warningForeground + : theme.colors.accentForeground + : theme.colors.textDim, }} title={isBusy ? 'Queue message' : 'Send message'} > diff --git a/src/renderer/components/HistoryDetailModal.tsx b/src/renderer/components/HistoryDetailModal.tsx index 9aa9e8fb8..3b0f4df78 100644 --- a/src/renderer/components/HistoryDetailModal.tsx +++ b/src/renderer/components/HistoryDetailModal.tsx @@ -198,7 +198,7 @@ export function HistoryDetailModal({ return (
{/* Backdrop */} -
+
{/* Modal */}
{entry.success ? ( entry.validated ? ( - + ) : ( ) @@ -606,7 +606,7 @@ export function HistoryDetailModal({ className="fixed inset-0 flex items-center justify-center z-[10001]" onClick={() => setShowDeleteConfirm(false)} > -
+
Got it diff --git a/src/renderer/components/ImageDiffViewer.tsx b/src/renderer/components/ImageDiffViewer.tsx index bcb5ea163..9211e4e5c 100644 --- a/src/renderer/components/ImageDiffViewer.tsx +++ b/src/renderer/components/ImageDiffViewer.tsx @@ -95,7 +95,7 @@ export function ImageDiffViewer({ {isNewFile && ( New file @@ -103,7 +103,7 @@ export function ImageDiffViewer({ {isDeletedFile && ( Deleted @@ -121,12 +121,12 @@ export function ImageDiffViewer({
- Before + Before {isNewFile ? '(file did not exist)' : oldPath} @@ -175,12 +175,12 @@ export function ImageDiffViewer({
- After + After {isDeletedFile ? '(file deleted)' : newPath} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView.tsx index b678f9aff..ed274875f 100644 --- a/src/renderer/components/InlineWizard/DocumentGenerationView.tsx +++ b/src/renderer/components/InlineWizard/DocumentGenerationView.tsx @@ -211,7 +211,7 @@ function ImagePreview({ className="absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" style={{ backgroundColor: theme.colors.error, - color: 'white', + color: theme.colors.errorForeground, }} title="Remove image" > @@ -220,8 +220,8 @@ function ImagePreview({
{filename} @@ -1184,7 +1184,7 @@ export function DocumentGenerationView({ className="mt-8 px-6 py-3 text-base font-semibold rounded-lg transition-all hover:opacity-90 hover:scale-105" style={{ backgroundColor: theme.colors.success, - color: 'white', + color: theme.colors.successForeground, }} > Exit Wizard diff --git a/src/renderer/components/KeyboardMasteryCelebration.tsx b/src/renderer/components/KeyboardMasteryCelebration.tsx index f4edb2f99..a878311d2 100644 --- a/src/renderer/components/KeyboardMasteryCelebration.tsx +++ b/src/renderer/components/KeyboardMasteryCelebration.tsx @@ -217,7 +217,7 @@ export function KeyboardMasteryCelebration({
{/* Modal */} diff --git a/src/renderer/components/LeaderboardRegistrationModal.tsx b/src/renderer/components/LeaderboardRegistrationModal.tsx index 2c89a517e..3d67f948c 100644 --- a/src/renderer/components/LeaderboardRegistrationModal.tsx +++ b/src/renderer/components/LeaderboardRegistrationModal.tsx @@ -1141,7 +1141,7 @@ export function LeaderboardRegistrationModal({ className="w-full px-3 py-2 text-xs font-medium rounded transition-colors flex items-center justify-center gap-2 disabled:opacity-50" style={{ backgroundColor: theme.colors.accent, - color: '#fff', + color: theme.colors.accentForeground, }} > {isResending ? ( @@ -1199,7 +1199,7 @@ export function LeaderboardRegistrationModal({ className="px-3 py-2 text-xs font-medium rounded transition-colors disabled:opacity-50" style={{ backgroundColor: theme.colors.accent, - color: '#fff', + color: theme.colors.accentForeground, }} > Submit @@ -1253,7 +1253,7 @@ export function LeaderboardRegistrationModal({ className="px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-1.5" style={{ backgroundColor: theme.colors.error, - color: '#fff', + color: theme.colors.errorForeground, }} > diff --git a/src/renderer/components/LightboxModal.tsx b/src/renderer/components/LightboxModal.tsx index d1b97eceb..2ea092dc5 100644 --- a/src/renderer/components/LightboxModal.tsx +++ b/src/renderer/components/LightboxModal.tsx @@ -152,6 +152,23 @@ export function LightboxModal({ success: '#22c55e', warning: '#eab308', error: '#ef4444', + info: '#3b82f6', + successForeground: '#1a1a1a', + warningForeground: '#1a1a1a', + errorForeground: '#1a1a1a', + successDim: 'rgba(34, 197, 94, 0.15)', + warningDim: 'rgba(234, 179, 8, 0.15)', + errorDim: 'rgba(239, 68, 68, 0.15)', + infoDim: 'rgba(59, 130, 246, 0.15)', + diffAddition: '#22c55e', + diffAdditionBg: 'rgba(34, 197, 94, 0.15)', + diffDeletion: '#ef4444', + diffDeletionBg: 'rgba(239, 68, 68, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }; diff --git a/src/renderer/components/LogViewer.tsx b/src/renderer/components/LogViewer.tsx index 4d11d60ac..590009e27 100644 --- a/src/renderer/components/LogViewer.tsx +++ b/src/renderer/components/LogViewer.tsx @@ -41,15 +41,17 @@ const LOG_LEVEL_PRIORITY: Record = { error: 3, }; -// Log level color mappings -const LOG_LEVEL_COLORS: Record = { - debug: { fg: '#6366f1', bg: 'rgba(99, 102, 241, 0.15)' }, // Indigo - info: { fg: '#3b82f6', bg: 'rgba(59, 130, 246, 0.15)' }, // Blue - warn: { fg: '#f59e0b', bg: 'rgba(245, 158, 11, 0.15)' }, // Amber - error: { fg: '#ef4444', bg: 'rgba(239, 68, 68, 0.15)' }, // Red - toast: { fg: '#a855f7', bg: 'rgba(168, 85, 247, 0.15)' }, // Purple - autorun: { fg: '#f97316', bg: 'rgba(249, 115, 22, 0.15)' }, // Orange -}; +// Log level color mappings - uses theme tokens where available +function getLogLevelColors(theme: Theme): Record { + return { + debug: { fg: '#6366f1', bg: 'rgba(99, 102, 241, 0.15)' }, // Indigo + info: { fg: theme.colors.info, bg: theme.colors.infoDim }, + warn: { fg: theme.colors.warning, bg: theme.colors.warningDim }, + error: { fg: theme.colors.error, bg: theme.colors.errorDim }, + toast: { fg: '#a855f7', bg: 'rgba(168, 85, 247, 0.15)' }, // Purple + autorun: { fg: '#f97316', bg: 'rgba(249, 115, 22, 0.15)' }, // Orange + }; +} export function LogViewer({ theme, @@ -356,8 +358,9 @@ export function LogViewer({ } }; - const getLevelColor = (level: string) => LOG_LEVEL_COLORS[level]?.fg ?? theme.colors.textDim; - const getLevelBgColor = (level: string) => LOG_LEVEL_COLORS[level]?.bg ?? 'transparent'; + const logLevelColors = getLogLevelColors(theme); + const getLevelColor = (level: string) => logLevelColors[level]?.fg ?? theme.colors.textDim; + const getLevelBgColor = (level: string) => logLevelColors[level]?.bg ?? 'transparent'; return (
selectedLevels.has(level)) - ? 'white' + ? theme.colors.accentForeground : theme.colors.textDim, border: `1px solid ${Array.from(enabledLevels).every((level) => selectedLevels.has(level)) ? theme.colors.accent : theme.colors.border}`, }} @@ -508,7 +511,7 @@ export function LogViewer({ backgroundColor: isEnabled && isSelected ? getLevelColor(level) : 'transparent', color: isEnabled ? isSelected - ? 'white' + ? theme.colors.accentForeground : theme.colors.textDim : theme.colors.textDim, border: `1px solid ${isEnabled && isSelected ? getLevelColor(level) : theme.colors.border}`, @@ -643,7 +646,7 @@ export function LogViewer({ return ( {project} @@ -654,7 +657,7 @@ export function LogViewer({ {log.level === 'autorun' && log.context && ( {log.context} diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index 2bb07251d..df71f2acb 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -108,6 +108,8 @@ interface MainPanelProps { filePreviewLoading?: { name: string; path: string } | null; markdownEditMode: boolean; // FilePreview: whether editing file content chatRawTextMode: boolean; // TerminalOutput: whether to show raw text in AI responses + autoScrollAiMode: boolean; // Whether to auto-scroll in AI mode + setAutoScrollAiMode: (value: boolean) => void; // Toggle auto-scroll in AI mode shortcuts: Record; rightPanelOpen: boolean; maxOutputLines: number; @@ -1233,7 +1235,7 @@ export const MainPanel = React.memo( backgroundColor: isCurrentSessionStopping ? theme.colors.warning : theme.colors.error, - color: isCurrentSessionStopping ? theme.colors.bgMain : 'white', + color: isCurrentSessionStopping ? theme.colors.warningForeground : theme.colors.errorForeground, pointerEvents: isCurrentSessionStopping ? 'none' : 'auto', }} title={ @@ -1610,7 +1612,7 @@ export const MainPanel = React.memo( className="px-2 py-1 text-xs font-medium rounded hover:opacity-80 transition-opacity" style={{ backgroundColor: theme.colors.error, - color: '#ffffff', + color: theme.colors.errorForeground, }} > View Details @@ -1800,6 +1802,8 @@ export const MainPanel = React.memo( ? () => props.refreshFileTree?.(activeSession.id) : undefined } + autoScrollAiMode={props.autoScrollAiMode} + setAutoScrollAiMode={props.setAutoScrollAiMode} onOpenInTab={props.onOpenSavedFileInTab} /> )} @@ -1919,7 +1923,7 @@ export const MainPanel = React.memo( style={{ backgroundColor: theme.colors.accent, color: theme.colors.accentForeground, - textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)', + textShadow: `0 1px 2px ${theme.colors.shadow}`, }} > {copyNotification} diff --git a/src/renderer/components/MarketplaceModal.tsx b/src/renderer/components/MarketplaceModal.tsx index c5bed59bd..f0d1f8cd1 100644 --- a/src/renderer/components/MarketplaceModal.tsx +++ b/src/renderer/components/MarketplaceModal.tsx @@ -177,8 +177,8 @@ function PlaybookTile({ playbook, theme, isSelected, onSelect }: PlaybookTilePro @@ -357,8 +357,8 @@ function PlaybookDetailView({ @@ -534,8 +534,8 @@ function PlaybookDetailView({ @@ -1075,7 +1075,7 @@ export function MarketplaceModal({ const modalContent = (
{ + e.currentTarget.style.backgroundColor = theme.colors.hoverBg; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = ''; + }} > Continue Merge @@ -163,7 +169,7 @@ function CancelConfirmDialog({ className="px-3 py-1.5 rounded text-xs font-medium transition-colors" style={{ backgroundColor: theme.colors.error, - color: '#fff', + color: theme.colors.errorForeground, }} > Cancel Merge @@ -307,8 +313,14 @@ export function MergeProgressModal({
) : isActive ? ( Cancel diff --git a/src/renderer/components/NotificationsPanel.tsx b/src/renderer/components/NotificationsPanel.tsx index 380d2ac12..90e04418f 100644 --- a/src/renderer/components/NotificationsPanel.tsx +++ b/src/renderer/components/NotificationsPanel.tsx @@ -130,7 +130,7 @@ export function NotificationsPanel({ className="px-3 py-2 rounded text-xs font-medium transition-all flex items-center gap-1" style={{ backgroundColor: theme.colors.error, - color: '#fff', + color: theme.colors.errorForeground, border: `1px solid ${theme.colors.error}`, }} > diff --git a/src/renderer/components/OpenSpecCommandsPanel.tsx b/src/renderer/components/OpenSpecCommandsPanel.tsx index 93b7f8f73..9c833ce7b 100644 --- a/src/renderer/components/OpenSpecCommandsPanel.tsx +++ b/src/renderer/components/OpenSpecCommandsPanel.tsx @@ -269,7 +269,7 @@ export function OpenSpecCommandsPanel({ theme }: OpenSpecCommandsPanelProps) { className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all" style={{ backgroundColor: theme.colors.success, - color: '#000000', + color: theme.colors.successForeground, }} > diff --git a/src/renderer/components/PlaygroundPanel.tsx b/src/renderer/components/PlaygroundPanel.tsx index 0c373d10e..d7a658cb2 100644 --- a/src/renderer/components/PlaygroundPanel.tsx +++ b/src/renderer/components/PlaygroundPanel.tsx @@ -758,7 +758,7 @@ ${staggerDelays.map((delay, i) => `svg.wand-sparkle-active path:nth-child(${i + className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded font-medium transition-colors" style={{ backgroundColor: theme.colors.accent, - color: '#fff', + color: theme.colors.accentForeground, }} > @@ -828,8 +828,8 @@ ${staggerDelays.map((delay, i) => `svg.wand-sparkle-active path:nth-child(${i + onClick={() => setShowKeyboardMasteryCelebration(true)} className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded font-medium transition-colors" style={{ - backgroundColor: '#9B59B6', - color: '#fff', + backgroundColor: theme.colors.accent, + color: theme.colors.accentForeground, }} > @@ -1181,7 +1181,7 @@ ${staggerDelays.map((delay, i) => `svg.wand-sparkle-active path:nth-child(${i + className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-bold text-lg transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed" style={{ backgroundColor: theme.colors.accent, - color: '#fff', + color: theme.colors.accentForeground, }} > @@ -1193,7 +1193,7 @@ ${staggerDelays.map((delay, i) => `svg.wand-sparkle-active path:nth-child(${i + className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded font-medium transition-colors" style={{ backgroundColor: copySuccess ? theme.colors.success : theme.colors.bgMain, - color: copySuccess ? '#fff' : theme.colors.textMain, + color: copySuccess ? theme.colors.successForeground : theme.colors.textMain, border: `1px solid ${copySuccess ? theme.colors.success : theme.colors.border}`, }} > @@ -1355,7 +1355,7 @@ ${staggerDelays.map((delay, i) => `svg.wand-sparkle-active path:nth-child(${i + className="px-3 py-1 rounded text-sm font-medium transition-colors" style={{ backgroundColor: batonActive ? theme.colors.accent : theme.colors.bgMain, - color: batonActive ? '#fff' : theme.colors.textMain, + color: batonActive ? theme.colors.accentForeground : theme.colors.textMain, }} > {batonActive ? 'Active' : 'Paused'} @@ -1497,7 +1497,7 @@ ${staggerDelays.map((delay, i) => `svg.wand-sparkle-active path:nth-child(${i + className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded font-medium transition-colors" style={{ backgroundColor: batonCopySuccess ? theme.colors.success : theme.colors.bgMain, - color: batonCopySuccess ? '#fff' : theme.colors.textMain, + color: batonCopySuccess ? theme.colors.successForeground : theme.colors.textMain, border: `1px solid ${batonCopySuccess ? theme.colors.success : theme.colors.border}`, }} > diff --git a/src/renderer/components/ProcessMonitor.tsx b/src/renderer/components/ProcessMonitor.tsx index 74df2d42a..f6cceb1ed 100644 --- a/src/renderer/components/ProcessMonitor.tsx +++ b/src/renderer/components/ProcessMonitor.tsx @@ -984,8 +984,8 @@ export function ProcessMonitor(props: ProcessMonitorProps) { WIZARD @@ -995,8 +995,8 @@ export function ProcessMonitor(props: ProcessMonitorProps) { GENERATING @@ -1580,7 +1580,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) { {killConfirmProcessId && (
setKillConfirmProcessId(null)} >
killProcess(killConfirmProcessId)} className="px-3 py-1.5 rounded text-sm flex items-center gap-2" - style={{ backgroundColor: theme.colors.error, color: 'white' }} + style={{ backgroundColor: theme.colors.error, color: theme.colors.errorForeground }} disabled={isKilling} autoFocus > diff --git a/src/renderer/components/PromptComposerModal.tsx b/src/renderer/components/PromptComposerModal.tsx index af9d0b6ce..f2a964ce6 100644 --- a/src/renderer/components/PromptComposerModal.tsx +++ b/src/renderer/components/PromptComposerModal.tsx @@ -237,7 +237,7 @@ export function PromptComposerModal({ return (
{ if (e.target === e.currentTarget) { onSubmit(value); diff --git a/src/renderer/components/QueuedItemsList.tsx b/src/renderer/components/QueuedItemsList.tsx index a1f83d52f..7cccccd66 100644 --- a/src/renderer/components/QueuedItemsList.tsx +++ b/src/renderer/components/QueuedItemsList.tsx @@ -234,7 +234,7 @@ export const QueuedItemsList = memo( {queueRemoveConfirmId && (
setQueueRemoveConfirmId(null)} onKeyDown={handleModalKeyDown} > @@ -262,7 +262,7 @@ export const QueuedItemsList = memo( @@ -898,7 +931,8 @@ const LogItemComponent = memo( prevProps.theme === nextProps.theme && prevProps.maxOutputLines === nextProps.maxOutputLines && prevProps.markdownEditMode === nextProps.markdownEditMode && - prevProps.fontFamily === nextProps.fontFamily + prevProps.fontFamily === nextProps.fontFamily && + prevProps.userMessageAlignment === nextProps.userMessageAlignment ); } ); @@ -978,6 +1012,9 @@ interface TerminalOutputProps { onFileClick?: (path: string) => void; // Callback when a file link is clicked onShowErrorDetails?: () => void; // Callback to show the error modal (for error log entries) onFileSaved?: () => void; // Callback when markdown content is saved to file (e.g., to refresh file list) + autoScrollAiMode?: boolean; // Whether to auto-scroll in AI mode (like terminal mode) + setAutoScrollAiMode?: (value: boolean) => void; // Toggle auto-scroll in AI mode + userMessageAlignment?: 'left' | 'right'; // User message bubble alignment (default: right) onOpenInTab?: (file: { path: string; name: string; @@ -1020,6 +1057,9 @@ export const TerminalOutput = memo( onFileClick, onShowErrorDetails, onFileSaved, + autoScrollAiMode, + setAutoScrollAiMode, + userMessageAlignment = 'right', onOpenInTab, } = props; @@ -1076,6 +1116,11 @@ export const TerminalOutput = memo( const lastLogCountRef = useRef(0); // Track previous isAtBottom to detect changes for callback const prevIsAtBottomRef = useRef(true); + // Track whether auto-scroll is paused because user scrolled up (state so button re-renders) + const [autoScrollPaused, setAutoScrollPaused] = useState(false); + // Guard flag: prevents the scroll handler from pausing auto-scroll + // during programmatic scrollTo() calls from the MutationObserver effect. + const isProgrammaticScrollRef = useRef(false); // Track read state per tab - stores the log count when user scrolled to bottom const tabReadStateRef = useRef>(new Map()); @@ -1329,10 +1374,23 @@ export const TerminalOutput = memo( if (atBottom) { setHasNewMessages(false); setNewMessageCount(0); + // Resume auto-scroll when user scrolls back to bottom + setAutoScrollPaused(false); // Save read state for current tab if (activeTabId) { tabReadStateRef.current.set(activeTabId, filteredLogs.length); } + } else if (autoScrollAiMode) { + if (isProgrammaticScrollRef.current) { + // This scroll event was triggered by our own scrollTo() call — + // consume the guard flag here inside the throttled handler to avoid + // the race where queueMicrotask clears the flag before a deferred + // throttled invocation fires (throttle delay is 16ms > microtask). + isProgrammaticScrollRef.current = false; + } else { + // Genuine user scroll away from bottom — pause auto-scroll + setAutoScrollPaused(true); + } } // Throttled scroll position save (200ms) @@ -1345,7 +1403,13 @@ export const TerminalOutput = memo( scrollSaveTimerRef.current = null; }, 200); } - }, [activeTabId, filteredLogs.length, onScrollPositionChange, onAtBottomChange]); + }, [ + activeTabId, + filteredLogs.length, + onScrollPositionChange, + onAtBottomChange, + autoScrollAiMode, + ]); // PERF: Throttle at 16ms (60fps) instead of 4ms to reduce state updates during scroll const handleScroll = useThrottledCallback(handleScrollInner, 16); @@ -1388,7 +1452,11 @@ export const TerminalOutput = memo( lastLogCountRef.current = currentCount; }, [activeTabId]); // Only run when tab changes, not when filteredLogs changes - // Detect new messages when user is not at bottom (while staying on same tab) + // Detect new messages when user is not at bottom (while staying on same tab). + // NOTE: This intentionally uses filteredLogs.length (not the MutationObserver) because + // unread badge counts should only increment on NEW log entries, not on in-place text + // updates (thinking stream growth). The MutationObserver handles scroll triggering; + // this effect handles the unread badge. useEffect(() => { const currentCount = filteredLogs.length; if (currentCount > lastLogCountRef.current) { @@ -1416,21 +1484,69 @@ export const TerminalOutput = memo( lastLogCountRef.current = currentCount; }, [filteredLogs.length, isAtBottom, activeTabId]); - // Auto-scroll to bottom in terminal mode when new output arrives - // Terminal mode should always auto-scroll since users expect to see command output immediately + // Reset auto-scroll pause when user explicitly re-enables auto-scroll (button or shortcut) useEffect(() => { - if (session.inputMode === 'terminal' && scrollContainerRef.current) { - // Use requestAnimationFrame to ensure DOM has updated with new content + if (autoScrollAiMode) { + setAutoScrollPaused(false); + } + }, [autoScrollAiMode]); + + // Auto-scroll to bottom when DOM content changes in the scroll container. + // Uses MutationObserver to detect ALL content mutations — new nodes (log entries), + // text changes (thinking stream growth), and attribute changes (tool status updates). + // This replaces the previous filteredLogs.length dependency, which missed in-place + // text updates during thinking/tool streaming (GitHub issue #402). + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const shouldAutoScroll = () => + session.inputMode === 'terminal' || + (session.inputMode === 'ai' && autoScrollAiMode && !autoScrollPaused); + + const scrollToBottom = () => { + if (!scrollContainerRef.current) return; requestAnimationFrame(() => { if (scrollContainerRef.current) { + // Set guard flag BEFORE scrollTo — the throttled scroll handler + // checks this flag and consumes it (clears it) when it fires, + // preventing the programmatic scroll from being misinterpreted + // as a user scroll-up that should pause auto-scroll. + isProgrammaticScrollRef.current = true; scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, - behavior: 'smooth', + behavior: 'auto', }); + // Fallback: if scrollTo is a no-op (already at bottom), the browser + // won't fire a scroll event, so the handler never consumes the guard. + // Clear it after 32ms (2x the 16ms throttle window) to prevent a + // stale true from eating the next genuine user scroll-up. + setTimeout(() => { + isProgrammaticScrollRef.current = false; + }, 32); } }); + }; + + // Initial scroll on mount/dep change + if (shouldAutoScroll()) { + scrollToBottom(); } - }, [session.inputMode, session.shellLogs.length]); + + const observer = new MutationObserver(() => { + if (shouldAutoScroll()) { + scrollToBottom(); + } + }); + + observer.observe(container, { + childList: true, // New/removed DOM nodes (new log entries, tool events) + subtree: true, // Watch all descendants, not just direct children + characterData: true, // Text node mutations (thinking stream text growth) + }); + + return () => observer.disconnect(); + }, [session.inputMode, autoScrollAiMode, autoScrollPaused]); // Restore scroll position when component mounts or initialScrollTop changes // Uses requestAnimationFrame to ensure DOM is ready @@ -1464,18 +1580,6 @@ export const TerminalOutput = memo( }; }, []); - // Scroll to bottom function - const scrollToBottom = useCallback(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - top: scrollContainerRef.current.scrollHeight, - behavior: 'smooth', - }); - setHasNewMessages(false); - setNewMessageCount(0); - } - }, []); - // Helper to find last user command for echo stripping in terminal mode const getLastUserCommand = useCallback( (index: number): string | undefined => { @@ -1500,6 +1604,8 @@ export const TerminalOutput = memo( [theme] ); + const isAutoScrollActive = autoScrollAiMode && !autoScrollPaused; + return (
{proseStyles} {/* Native scroll log list */} + {/* overflow-anchor: disabled in AI mode when auto-scroll is off to prevent + browser from automatically keeping viewport pinned to bottom on new content */}
{/* Log entries */} @@ -1637,6 +1751,7 @@ export const TerminalOutput = memo( onFileClick={onFileClick} onShowErrorDetails={onShowErrorDetails} onSaveToFile={handleSaveToFile} + userMessageAlignment={userMessageAlignment} /> ))} @@ -1685,26 +1800,65 @@ export const TerminalOutput = memo(
- {/* New Message Indicator - floating arrow button (AI mode only, terminal auto-scrolls) */} - {hasNewMessages && !isAtBottom && session.inputMode === 'ai' && ( - - )} + {/* Auto-scroll toggle — positioned opposite AI response side (AI mode only) */} + {/* Visible when: not at bottom (dimmed, click to pin) OR pinned at bottom (accent, click to unpin) */} + {session.inputMode === 'ai' && + setAutoScrollAiMode && + (!isAtBottom || isAutoScrollActive) && ( + + )} {/* Copied to Clipboard Notification */} {showCopiedNotification && ( diff --git a/src/renderer/components/TransferProgressModal.tsx b/src/renderer/components/TransferProgressModal.tsx index f8df784d7..dc4d053a7 100644 --- a/src/renderer/components/TransferProgressModal.tsx +++ b/src/renderer/components/TransferProgressModal.tsx @@ -184,7 +184,7 @@ function CancelConfirmDialog({ className="px-3 py-1.5 rounded text-xs font-medium transition-colors" style={{ backgroundColor: theme.colors.error, - color: '#fff', + color: theme.colors.errorForeground, }} > Cancel Transfer @@ -469,7 +469,7 @@ export function TransferProgressModal({ className="w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: theme.colors.success }} > - +
) : isActive ? (
diff --git a/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx b/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx index 5f9b3f06d..83aad3958 100644 --- a/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx +++ b/src/renderer/components/UsageDashboard/AgentComparisonChart.tsx @@ -236,7 +236,7 @@ export function AgentComparisonChart({ {agent.durationPercentage.toFixed(1)}% diff --git a/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx index d8c6b60de..f0ed18569 100644 --- a/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx +++ b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx @@ -178,7 +178,7 @@ export function AgentEfficiencyChart({ {percentage > 25 && ( {formatDuration(agent.avgDuration)} diff --git a/src/renderer/components/UsageDashboard/ChartErrorBoundary.tsx b/src/renderer/components/UsageDashboard/ChartErrorBoundary.tsx index 20d9243a1..46e6ab60c 100644 --- a/src/renderer/components/UsageDashboard/ChartErrorBoundary.tsx +++ b/src/renderer/components/UsageDashboard/ChartErrorBoundary.tsx @@ -146,7 +146,7 @@ export class ChartErrorBoundary extends Component { className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors" style={{ backgroundColor: theme.colors.accent, - color: '#ffffff', + color: theme.colors.accentForeground, }} onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.9'; diff --git a/src/renderer/components/UsageDashboard/PeakHoursChart.tsx b/src/renderer/components/UsageDashboard/PeakHoursChart.tsx index d391514c7..914c6c54b 100644 --- a/src/renderer/components/UsageDashboard/PeakHoursChart.tsx +++ b/src/renderer/components/UsageDashboard/PeakHoursChart.tsx @@ -207,7 +207,7 @@ export function PeakHoursChart({ backgroundColor: theme.colors.bgActivity, color: theme.colors.textMain, border: `1px solid ${theme.colors.border}`, - boxShadow: '0 2px 8px rgba(0,0,0,0.3)', + boxShadow: `0 2px 8px ${theme.colors.shadow}`, }} >
{formatHour(h.hour)}
diff --git a/src/renderer/components/UsageDashboard/SessionStats.tsx b/src/renderer/components/UsageDashboard/SessionStats.tsx index 1eeb7c297..6e930bcc4 100644 --- a/src/renderer/components/UsageDashboard/SessionStats.tsx +++ b/src/renderer/components/UsageDashboard/SessionStats.tsx @@ -270,7 +270,7 @@ export function SessionStats({ sessions, theme, colorBlindMode = false }: Sessio {percentage > 20 && ( {agent.count} diff --git a/src/renderer/components/Wizard/MaestroWizard.tsx b/src/renderer/components/Wizard/MaestroWizard.tsx index 155d4e54d..9e201c68a 100644 --- a/src/renderer/components/Wizard/MaestroWizard.tsx +++ b/src/renderer/components/Wizard/MaestroWizard.tsx @@ -439,7 +439,7 @@ export function MaestroWizard({ return (
{ // Close on backdrop click if (e.target === e.currentTarget) { diff --git a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx index 4dae3ace6..bdace000e 100644 --- a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx +++ b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx @@ -1256,7 +1256,7 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX. className="absolute top-2 right-2 w-5 h-5 rounded-full flex items-center justify-center" style={{ backgroundColor: tile.brandColor || theme.colors.accent }} > - +
)} @@ -1265,14 +1265,14 @@ export function AgentSelectionScreen({ theme }: AgentSelectionScreenProps): JSX.
{isDetected ? ( - + ) : ( - + )}
)} diff --git a/src/renderer/components/Wizard/screens/ConversationScreen.tsx b/src/renderer/components/Wizard/screens/ConversationScreen.tsx index 3a201b9b5..6af551bb0 100644 --- a/src/renderer/components/Wizard/screens/ConversationScreen.tsx +++ b/src/renderer/components/Wizard/screens/ConversationScreen.tsx @@ -1405,7 +1405,7 @@ export function ConversationScreen({ className="px-4 py-1.5 rounded text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-1" style={{ backgroundColor: theme.colors.error, - color: 'white', + color: theme.colors.errorForeground, ['--tw-ring-color' as any]: theme.colors.error, ['--tw-ring-offset-color' as any]: theme.colors.bgMain, }} @@ -1545,7 +1545,7 @@ export function ConversationScreen({ backgroundColor: inputValue.trim() && !state.isConversationLoading ? theme.colors.accent - : theme.colors.border, + : theme.colors.bgActivity, color: inputValue.trim() && !state.isConversationLoading ? theme.colors.accentForeground diff --git a/src/renderer/components/Wizard/shared/DocumentEditor.tsx b/src/renderer/components/Wizard/shared/DocumentEditor.tsx index b4e42570e..bba375c37 100644 --- a/src/renderer/components/Wizard/shared/DocumentEditor.tsx +++ b/src/renderer/components/Wizard/shared/DocumentEditor.tsx @@ -61,7 +61,7 @@ export function ImagePreview({ className="absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" style={{ backgroundColor: theme.colors.error, - color: 'white', + color: theme.colors.errorForeground, }} title="Remove image" > @@ -70,8 +70,8 @@ export function ImagePreview({
{filename} diff --git a/src/renderer/components/WorktreeConfigModal.tsx b/src/renderer/components/WorktreeConfigModal.tsx index 033d05ad0..2da98323d 100644 --- a/src/renderer/components/WorktreeConfigModal.tsx +++ b/src/renderer/components/WorktreeConfigModal.tsx @@ -179,7 +179,7 @@ export function WorktreeConfigModal({ return (
{/* Backdrop */} -
+
{/* Modal */}
= { fuzzyFileSearch: { id: 'fuzzyFileSearch', label: 'Fuzzy File Search', keys: ['Meta', 'g'] }, toggleBookmark: { id: 'toggleBookmark', label: 'Toggle Bookmark', keys: ['Meta', 'Shift', 'b'] }, openSymphony: { id: 'openSymphony', label: 'Maestro Symphony', keys: ['Meta', 'Shift', 'y'] }, + toggleAutoScroll: { + id: 'toggleAutoScroll', + label: 'Toggle Auto-Scroll AI Output', + keys: ['Alt', 'Meta', 's'], + }, directorNotes: { id: 'directorNotes', label: "Director's Notes", diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index f35def81a..d12454737 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -104,13 +104,13 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { // (e.g., when output search is open, user should still be able to toggle markdown mode) const isMarkdownToggleShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && keyLower === 'e'; - // Allow system utility shortcuts (Alt+Cmd+L for logs, Alt+Cmd+P for processes) even when modals are open + // Allow system utility shortcuts (Alt+Cmd+L for logs, Alt+Cmd+P for processes, Alt+Cmd+S for auto-scroll toggle) even when modals are open // NOTE: Must use e.code for Alt key combos on macOS because e.key produces special characters (e.g., Alt+P = π) const codeKeyLower = e.code?.replace('Key', '').toLowerCase() || ''; const isSystemUtilShortcut = e.altKey && (e.metaKey || e.ctrlKey) && - (codeKeyLower === 'l' || codeKeyLower === 'p' || codeKeyLower === 'u'); + (codeKeyLower === 'l' || codeKeyLower === 'p' || codeKeyLower === 'u' || codeKeyLower === 's'); // Allow session jump shortcuts (Alt+Cmd+NUMBER) even when modals are open // NOTE: Must use e.code for Alt key combos on macOS because e.key produces special characters const isSessionJumpShortcut = @@ -404,6 +404,10 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { e.preventDefault(); ctx.setSymphonyModalOpen(true); trackShortcut('openSymphony'); + } else if (ctx.isShortcut(e, 'toggleAutoScroll')) { + e.preventDefault(); + ctx.setAutoScrollAiMode(!ctx.autoScrollAiMode); + trackShortcut('toggleAutoScroll'); } else if (ctx.isShortcut(e, 'directorNotes')) { e.preventDefault(); ctx.setDirectorNotesOpen(true); diff --git a/src/renderer/hooks/props/useMainPanelProps.ts b/src/renderer/hooks/props/useMainPanelProps.ts index 06aed0a49..7d259942f 100644 --- a/src/renderer/hooks/props/useMainPanelProps.ts +++ b/src/renderer/hooks/props/useMainPanelProps.ts @@ -63,6 +63,8 @@ export interface UseMainPanelPropsDeps { filePreviewLoading: { name: string; path: string } | null; markdownEditMode: boolean; // FilePreview: whether editing file content chatRawTextMode: boolean; // TerminalOutput: whether to show raw text in AI responses + autoScrollAiMode: boolean; // Whether to auto-scroll in AI mode + setAutoScrollAiMode: (value: boolean) => void; // Toggle auto-scroll in AI mode shortcuts: Record; rightPanelOpen: boolean; maxOutputLines: number; @@ -334,6 +336,8 @@ export function useMainPanelProps(deps: UseMainPanelPropsDeps) { filePreviewLoading: deps.filePreviewLoading, markdownEditMode: deps.markdownEditMode, chatRawTextMode: deps.chatRawTextMode, + autoScrollAiMode: deps.autoScrollAiMode, + setAutoScrollAiMode: deps.setAutoScrollAiMode, shortcuts: deps.shortcuts, rightPanelOpen: deps.rightPanelOpen, maxOutputLines: deps.maxOutputLines, @@ -555,6 +559,8 @@ export function useMainPanelProps(deps: UseMainPanelPropsDeps) { deps.filePreviewLoading, deps.markdownEditMode, deps.chatRawTextMode, + deps.autoScrollAiMode, + deps.setAutoScrollAiMode, deps.shortcuts, deps.rightPanelOpen, deps.maxOutputLines, diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index 259d39add..e60e643b6 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -26,6 +26,7 @@ import type { KeyboardMasteryStats, ThinkingMode, DirectorNotesSettings, + EncoreFeatureFlags, } from '../../types'; import { useSettingsStore, @@ -270,6 +271,18 @@ export interface UseSettingsReturn { suppressWindowsWarning: boolean; setSuppressWindowsWarning: (value: boolean) => void; + // Auto-scroll in AI mode + autoScrollAiMode: boolean; + setAutoScrollAiMode: (value: boolean) => void; + + // Message alignment + userMessageAlignment: 'left' | 'right'; + setUserMessageAlignment: (value: 'left' | 'right') => void; + + // Encore Features - optional features disabled by default + encoreFeatures: EncoreFeatureFlags; + setEncoreFeatures: (value: EncoreFeatureFlags) => void; + // Director's Notes settings directorNotesSettings: DirectorNotesSettings; setDirectorNotesSettings: (value: DirectorNotesSettings) => void; @@ -279,6 +292,12 @@ export interface UseSettingsReturn { setWakatimeApiKey: (value: string) => void; wakatimeEnabled: boolean; setWakatimeEnabled: (value: boolean) => void; + + // Window chrome settings + useNativeTitleBar: boolean; + setUseNativeTitleBar: (value: boolean) => void; + autoHideMenuBar: boolean; + setAutoHideMenuBar: (value: boolean) => void; } export function useSettings(): UseSettingsReturn { diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index f9f4308da..dd784e3d1 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -30,6 +30,7 @@ import type { KeyboardMasteryStats, ThinkingMode, DirectorNotesSettings, + EncoreFeatureFlags, } from '../types'; import { DEFAULT_CUSTOM_THEME_COLORS } from '../constants/themes'; import { DEFAULT_SHORTCUTS, TAB_SHORTCUTS, FIXED_SHORTCUTS } from '../constants/shortcuts'; @@ -115,6 +116,10 @@ export const DEFAULT_ONBOARDING_STATS: OnboardingStats = { averageTasksPerPhase: 0, }; +export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = { + directorNotes: false, +}; + export const DEFAULT_DIRECTOR_NOTES_SETTINGS: DirectorNotesSettings = { provider: 'claude-code', defaultLookbackDays: 7, @@ -236,9 +241,14 @@ export interface SettingsStoreState { automaticTabNamingEnabled: boolean; fileTabAutoRefreshEnabled: boolean; suppressWindowsWarning: boolean; + autoScrollAiMode: boolean; + userMessageAlignment: 'left' | 'right'; + encoreFeatures: EncoreFeatureFlags; directorNotesSettings: DirectorNotesSettings; wakatimeApiKey: string; wakatimeEnabled: boolean; + useNativeTitleBar: boolean; + autoHideMenuBar: boolean; } export interface SettingsStoreActions { @@ -298,9 +308,14 @@ export interface SettingsStoreActions { setAutomaticTabNamingEnabled: (value: boolean) => void; setFileTabAutoRefreshEnabled: (value: boolean) => void; setSuppressWindowsWarning: (value: boolean) => void; + setAutoScrollAiMode: (value: boolean) => void; + setUserMessageAlignment: (value: 'left' | 'right') => void; + setEncoreFeatures: (value: EncoreFeatureFlags) => void; setDirectorNotesSettings: (value: DirectorNotesSettings) => void; setWakatimeApiKey: (value: string) => void; setWakatimeEnabled: (value: boolean) => void; + setUseNativeTitleBar: (value: boolean) => void; + setAutoHideMenuBar: (value: boolean) => void; // Async setters setLogLevel: (value: string) => Promise; @@ -436,9 +451,14 @@ export const useSettingsStore = create()((set, get) => ({ automaticTabNamingEnabled: true, fileTabAutoRefreshEnabled: false, suppressWindowsWarning: false, + autoScrollAiMode: false, + userMessageAlignment: 'right', + encoreFeatures: DEFAULT_ENCORE_FEATURES, directorNotesSettings: DEFAULT_DIRECTOR_NOTES_SETTINGS, wakatimeApiKey: '', wakatimeEnabled: false, + useNativeTitleBar: false, + autoHideMenuBar: false, // ============================================================================ // Simple Setters @@ -727,6 +747,21 @@ export const useSettingsStore = create()((set, get) => ({ window.maestro.settings.set('suppressWindowsWarning', value); }, + setAutoScrollAiMode: (value) => { + set({ autoScrollAiMode: value }); + window.maestro.settings.set('autoScrollAiMode', value); + }, + + setUserMessageAlignment: (value) => { + set({ userMessageAlignment: value }); + window.maestro.settings.set('userMessageAlignment', value); + }, + + setEncoreFeatures: (value) => { + set({ encoreFeatures: value }); + window.maestro.settings.set('encoreFeatures', value); + }, + setDirectorNotesSettings: (value) => { set({ directorNotesSettings: value }); window.maestro.settings.set('directorNotesSettings', value); @@ -742,6 +777,16 @@ export const useSettingsStore = create()((set, get) => ({ window.maestro.settings.set('wakatimeEnabled', value); }, + setUseNativeTitleBar: (value) => { + set({ useNativeTitleBar: value }); + window.maestro.settings.set('useNativeTitleBar', value); + }, + + setAutoHideMenuBar: (value) => { + set({ autoHideMenuBar: value }); + window.maestro.settings.set('autoHideMenuBar', value); + }, + // ============================================================================ // Async Setters // ============================================================================ @@ -1292,8 +1337,13 @@ export async function loadAllSettings(): Promise { if (allSettings['activeThemeId'] !== undefined) patch.activeThemeId = allSettings['activeThemeId'] as ThemeId; + // Custom theme migration: merge saved tokens with defaults so themes + // created with 13 tokens gain the 17 new tokens added in the WCAG update. if (allSettings['customThemeColors'] !== undefined) - patch.customThemeColors = allSettings['customThemeColors'] as ThemeColors; + patch.customThemeColors = { + ...DEFAULT_CUSTOM_THEME_COLORS, + ...(allSettings['customThemeColors'] as ThemeColors), + }; if (allSettings['customThemeBaseId'] !== undefined) patch.customThemeBaseId = allSettings['customThemeBaseId'] as ThemeId; @@ -1591,6 +1641,20 @@ export async function loadAllSettings(): Promise { if (allSettings['suppressWindowsWarning'] !== undefined) patch.suppressWindowsWarning = allSettings['suppressWindowsWarning'] as boolean; + if (allSettings['autoScrollAiMode'] !== undefined) + patch.autoScrollAiMode = allSettings['autoScrollAiMode'] as boolean; + + if (allSettings['userMessageAlignment'] !== undefined) + patch.userMessageAlignment = allSettings['userMessageAlignment'] as 'left' | 'right'; + + // Encore Features (merge with defaults to preserve new flags) + if (allSettings['encoreFeatures'] !== undefined) { + patch.encoreFeatures = { + ...DEFAULT_ENCORE_FEATURES, + ...(allSettings['encoreFeatures'] as Partial), + }; + } + // Director's Notes settings (merge with defaults to preserve new fields) if (allSettings['directorNotesSettings'] !== undefined) { patch.directorNotesSettings = { @@ -1605,6 +1669,12 @@ export async function loadAllSettings(): Promise { if (allSettings['wakatimeEnabled'] !== undefined) patch.wakatimeEnabled = allSettings['wakatimeEnabled'] as boolean; + if (allSettings['useNativeTitleBar'] !== undefined) + patch.useNativeTitleBar = allSettings['useNativeTitleBar'] as boolean; + + if (allSettings['autoHideMenuBar'] !== undefined) + patch.autoHideMenuBar = allSettings['autoHideMenuBar'] as boolean; + // Apply the entire patch in one setState call patch.settingsLoaded = true; useSettingsStore.setState(patch); @@ -1708,8 +1778,12 @@ export function getSettingsActions() { setAutomaticTabNamingEnabled: state.setAutomaticTabNamingEnabled, setFileTabAutoRefreshEnabled: state.setFileTabAutoRefreshEnabled, setSuppressWindowsWarning: state.setSuppressWindowsWarning, + setAutoScrollAiMode: state.setAutoScrollAiMode, + setEncoreFeatures: state.setEncoreFeatures, setDirectorNotesSettings: state.setDirectorNotesSettings, setWakatimeApiKey: state.setWakatimeApiKey, setWakatimeEnabled: state.setWakatimeEnabled, + setUseNativeTitleBar: state.setUseNativeTitleBar, + setAutoHideMenuBar: state.setAutoHideMenuBar, }; } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 217ace0d1..a195552b0 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -901,6 +901,12 @@ export interface LeaderboardSubmitResponse { }; } +// Encore Features - optional features that are disabled by default +// Each key is a feature ID, value indicates whether it's enabled +export interface EncoreFeatureFlags { + directorNotes: boolean; +} + // Director's Notes settings for synopsis generation export interface DirectorNotesSettings { /** Agent type to use for synopsis generation */ diff --git a/src/renderer/utils/markdownConfig.ts b/src/renderer/utils/markdownConfig.ts index 329c1627c..36632f41b 100644 --- a/src/renderer/utils/markdownConfig.ts +++ b/src/renderer/utils/markdownConfig.ts @@ -541,24 +541,24 @@ export function generateDiffViewStyles(theme: Theme): string { color: ${c.textMain} !important; } .diff-gutter-insert { - background-color: rgba(34, 197, 94, 0.1) !important; + background-color: ${c.diffAdditionBg} !important; } .diff-code-insert { - background-color: rgba(34, 197, 94, 0.15) !important; + background-color: ${c.diffAdditionBg} !important; color: ${c.textMain} !important; } .diff-gutter-delete { - background-color: rgba(239, 68, 68, 0.1) !important; + background-color: ${c.diffDeletionBg} !important; } .diff-code-delete { - background-color: rgba(239, 68, 68, 0.15) !important; + background-color: ${c.diffDeletionBg} !important; color: ${c.textMain} !important; } .diff-code-insert .diff-code-edit { - background-color: rgba(34, 197, 94, 0.3) !important; + background-color: ${c.diffAddition}40 !important; } .diff-code-delete .diff-code-edit { - background-color: rgba(239, 68, 68, 0.3) !important; + background-color: ${c.diffDeletion}40 !important; } .diff-hunk-header { background-color: ${c.bgActivity} !important; diff --git a/src/shared/theme-types.ts b/src/shared/theme-types.ts index 027e09e48..44bc86b62 100644 --- a/src/shared/theme-types.ts +++ b/src/shared/theme-types.ts @@ -41,6 +41,7 @@ export type ThemeMode = 'light' | 'dark' | 'vibe'; * Each color serves a specific purpose in the UI */ export interface ThemeColors { + // --- Core backgrounds --- /** Main background color for primary content areas */ bgMain: string; /** Sidebar background color */ @@ -49,10 +50,14 @@ export interface ThemeColors { bgActivity: string; /** Border color for dividers and outlines */ border: string; + + // --- Typography --- /** Primary text color */ textMain: string; /** Dimmed/secondary text color */ textDim: string; + + // --- Accent --- /** Accent color for highlights and interactive elements */ accent: string; /** Dimmed accent (typically with alpha transparency) */ @@ -61,12 +66,56 @@ export interface ThemeColors { accentText: string; /** Text color for use ON accent backgrounds (contrasting color) */ accentForeground: string; + + // --- Status colors --- /** Success state color (green tones) */ success: string; /** Warning state color (yellow/orange tones) */ warning: string; /** Error state color (red tones) */ error: string; + /** Info state color (blue tones) */ + info: string; + + // --- Status foregrounds (text ON status backgrounds) --- + /** Text color for use ON success backgrounds */ + successForeground: string; + /** Text color for use ON warning backgrounds */ + warningForeground: string; + /** Text color for use ON error backgrounds */ + errorForeground: string; + + // --- Status dim backgrounds (subtle badges/tags) --- + /** Dimmed success background for badges and tags */ + successDim: string; + /** Dimmed warning background for badges and tags */ + warningDim: string; + /** Dimmed error background for badges and tags */ + errorDim: string; + /** Dimmed info background for badges and tags */ + infoDim: string; + + // --- Git diff colors --- + /** Color for added lines/files in diffs */ + diffAddition: string; + /** Background for added lines/files in diffs */ + diffAdditionBg: string; + /** Color for deleted lines/files in diffs */ + diffDeletion: string; + /** Background for deleted lines/files in diffs */ + diffDeletionBg: string; + + // --- Overlay and interactive states --- + /** Modal/overlay backdrop color */ + overlay: string; + /** Heavy overlay for wizard/fullscreen modals */ + overlayHeavy: string; + /** Subtle hover state background */ + hoverBg: string; + /** Selected/active state background */ + activeBg: string; + /** Standard elevation shadow color */ + shadow: string; } /** diff --git a/src/shared/themes.ts b/src/shared/themes.ts index 8bd1455a0..853710cde 100644 --- a/src/shared/themes.ts +++ b/src/shared/themes.ts @@ -32,6 +32,23 @@ export const THEMES: Record = { success: '#50fa7b', warning: '#ffb86c', error: '#ff5555', + info: '#8be9fd', + successForeground: '#282a36', + warningForeground: '#282a36', + errorForeground: '#282a36', + successDim: 'rgba(80, 250, 123, 0.15)', + warningDim: 'rgba(255, 184, 108, 0.15)', + errorDim: 'rgba(255, 85, 85, 0.15)', + infoDim: 'rgba(139, 233, 253, 0.15)', + diffAddition: '#50fa7b', + diffAdditionBg: 'rgba(80, 250, 123, 0.15)', + diffDeletion: '#ff5555', + diffDeletionBg: 'rgba(255, 85, 85, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, monokai: { @@ -52,6 +69,23 @@ export const THEMES: Record = { success: '#a6e22e', warning: '#e6db74', error: '#f92672', + info: '#66d9ef', + successForeground: '#272822', + warningForeground: '#272822', + errorForeground: '#272822', + successDim: 'rgba(166, 226, 46, 0.15)', + warningDim: 'rgba(230, 219, 116, 0.15)', + errorDim: 'rgba(249, 38, 114, 0.15)', + infoDim: 'rgba(102, 217, 239, 0.15)', + diffAddition: '#a6e22e', + diffAdditionBg: 'rgba(166, 226, 46, 0.15)', + diffDeletion: '#f92672', + diffDeletionBg: 'rgba(249, 38, 114, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, nord: { @@ -72,6 +106,23 @@ export const THEMES: Record = { success: '#a3be8c', warning: '#ebcb8b', error: '#bf616a', + info: '#81a1c1', + successForeground: '#2e3440', + warningForeground: '#2e3440', + errorForeground: '#2e3440', + successDim: 'rgba(163, 190, 140, 0.15)', + warningDim: 'rgba(235, 203, 139, 0.15)', + errorDim: 'rgba(191, 97, 106, 0.15)', + infoDim: 'rgba(129, 161, 193, 0.15)', + diffAddition: '#a3be8c', + diffAdditionBg: 'rgba(163, 190, 140, 0.15)', + diffDeletion: '#bf616a', + diffDeletionBg: 'rgba(191, 97, 106, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, 'tokyo-night': { @@ -92,6 +143,23 @@ export const THEMES: Record = { success: '#9ece6a', warning: '#e0af68', error: '#f7768e', + info: '#7dcfff', + successForeground: '#1a1b26', + warningForeground: '#1a1b26', + errorForeground: '#1a1b26', + successDim: 'rgba(158, 206, 106, 0.15)', + warningDim: 'rgba(224, 175, 104, 0.15)', + errorDim: 'rgba(247, 118, 142, 0.15)', + infoDim: 'rgba(125, 207, 255, 0.15)', + diffAddition: '#9ece6a', + diffAdditionBg: 'rgba(158, 206, 106, 0.15)', + diffDeletion: '#f7768e', + diffDeletionBg: 'rgba(247, 118, 142, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, 'catppuccin-mocha': { @@ -112,6 +180,23 @@ export const THEMES: Record = { success: '#a6e3a1', warning: '#fab387', error: '#f38ba8', + info: '#89b4fa', + successForeground: '#1e1e2e', + warningForeground: '#1e1e2e', + errorForeground: '#1e1e2e', + successDim: 'rgba(166, 227, 161, 0.15)', + warningDim: 'rgba(250, 179, 135, 0.15)', + errorDim: 'rgba(243, 139, 168, 0.15)', + infoDim: 'rgba(137, 180, 250, 0.15)', + diffAddition: '#a6e3a1', + diffAdditionBg: 'rgba(166, 227, 161, 0.15)', + diffDeletion: '#f38ba8', + diffDeletionBg: 'rgba(243, 139, 168, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, 'gruvbox-dark': { @@ -132,6 +217,23 @@ export const THEMES: Record = { success: '#b8bb26', warning: '#fabd2f', error: '#fb4934', + info: '#83a598', + successForeground: '#282828', + warningForeground: '#282828', + errorForeground: '#282828', + successDim: 'rgba(184, 187, 38, 0.15)', + warningDim: 'rgba(250, 189, 47, 0.15)', + errorDim: 'rgba(251, 73, 52, 0.15)', + infoDim: 'rgba(131, 165, 152, 0.15)', + diffAddition: '#b8bb26', + diffAdditionBg: 'rgba(184, 187, 38, 0.15)', + diffDeletion: '#fb4934', + diffDeletionBg: 'rgba(251, 73, 52, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, // Light themes @@ -153,6 +255,23 @@ export const THEMES: Record = { success: '#1a7f37', warning: '#9a6700', error: '#cf222e', + info: '#0969da', + successForeground: '#ffffff', + warningForeground: '#ffffff', + errorForeground: '#ffffff', + successDim: 'rgba(26, 127, 55, 0.1)', + warningDim: 'rgba(154, 103, 0, 0.1)', + errorDim: 'rgba(207, 34, 46, 0.1)', + infoDim: 'rgba(9, 105, 218, 0.1)', + diffAddition: '#1a7f37', + diffAdditionBg: 'rgba(26, 127, 55, 0.1)', + diffDeletion: '#cf222e', + diffDeletionBg: 'rgba(207, 34, 46, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }, 'solarized-light': { @@ -173,6 +292,23 @@ export const THEMES: Record = { success: '#859900', warning: '#b58900', error: '#dc322f', + info: '#268bd2', + successForeground: '#fdf6e3', + warningForeground: '#fdf6e3', + errorForeground: '#fdf6e3', + successDim: 'rgba(133, 153, 0, 0.1)', + warningDim: 'rgba(181, 137, 0, 0.1)', + errorDim: 'rgba(220, 50, 47, 0.1)', + infoDim: 'rgba(38, 139, 210, 0.1)', + diffAddition: '#859900', + diffAdditionBg: 'rgba(133, 153, 0, 0.1)', + diffDeletion: '#dc322f', + diffDeletionBg: 'rgba(220, 50, 47, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }, 'one-light': { @@ -193,6 +329,23 @@ export const THEMES: Record = { success: '#50a14f', warning: '#c18401', error: '#e45649', + info: '#4078f2', + successForeground: '#ffffff', + warningForeground: '#ffffff', + errorForeground: '#ffffff', + successDim: 'rgba(80, 161, 79, 0.1)', + warningDim: 'rgba(193, 132, 1, 0.1)', + errorDim: 'rgba(228, 86, 73, 0.1)', + infoDim: 'rgba(64, 120, 242, 0.1)', + diffAddition: '#50a14f', + diffAdditionBg: 'rgba(80, 161, 79, 0.1)', + diffDeletion: '#e45649', + diffDeletionBg: 'rgba(228, 86, 73, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }, 'gruvbox-light': { @@ -213,6 +366,23 @@ export const THEMES: Record = { success: '#98971a', warning: '#d79921', error: '#cc241d', + info: '#458588', + successForeground: '#fbf1c7', + warningForeground: '#fbf1c7', + errorForeground: '#fbf1c7', + successDim: 'rgba(152, 151, 26, 0.1)', + warningDim: 'rgba(215, 153, 33, 0.1)', + errorDim: 'rgba(204, 36, 29, 0.1)', + infoDim: 'rgba(69, 133, 136, 0.1)', + diffAddition: '#98971a', + diffAdditionBg: 'rgba(152, 151, 26, 0.1)', + diffDeletion: '#cc241d', + diffDeletionBg: 'rgba(204, 36, 29, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }, 'catppuccin-latte': { @@ -233,6 +403,23 @@ export const THEMES: Record = { success: '#40a02b', warning: '#fe640b', error: '#d20f39', + info: '#1e66f5', + successForeground: '#ffffff', + warningForeground: '#ffffff', + errorForeground: '#ffffff', + successDim: 'rgba(64, 160, 43, 0.1)', + warningDim: 'rgba(254, 100, 11, 0.1)', + errorDim: 'rgba(210, 15, 57, 0.1)', + infoDim: 'rgba(30, 102, 245, 0.1)', + diffAddition: '#40a02b', + diffAdditionBg: 'rgba(64, 160, 43, 0.1)', + diffDeletion: '#d20f39', + diffDeletionBg: 'rgba(210, 15, 57, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }, 'ayu-light': { @@ -253,6 +440,23 @@ export const THEMES: Record = { success: '#86b300', warning: '#f2ae49', error: '#f07171', + info: '#399ee6', + successForeground: '#1a1a1a', + warningForeground: '#1a1a1a', + errorForeground: '#ffffff', + successDim: 'rgba(134, 179, 0, 0.1)', + warningDim: 'rgba(242, 174, 73, 0.1)', + errorDim: 'rgba(240, 113, 113, 0.1)', + infoDim: 'rgba(57, 158, 230, 0.1)', + diffAddition: '#86b300', + diffAdditionBg: 'rgba(134, 179, 0, 0.1)', + diffDeletion: '#f07171', + diffDeletionBg: 'rgba(240, 113, 113, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }, // Vibe themes @@ -274,6 +478,23 @@ export const THEMES: Record = { success: '#7cb342', warning: '#d4af37', error: '#da70d6', + info: '#b89fd0', + successForeground: '#1a0f24', + warningForeground: '#1a0f24', + errorForeground: '#1a0f24', + successDim: 'rgba(124, 179, 66, 0.15)', + warningDim: 'rgba(212, 175, 55, 0.15)', + errorDim: 'rgba(218, 112, 214, 0.15)', + infoDim: 'rgba(184, 159, 208, 0.15)', + diffAddition: '#7cb342', + diffAdditionBg: 'rgba(124, 179, 66, 0.15)', + diffDeletion: '#da70d6', + diffDeletionBg: 'rgba(218, 112, 214, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, 'maestros-choice': { @@ -294,6 +515,23 @@ export const THEMES: Record = { success: '#66d9a0', warning: '#f4c430', error: '#e05070', + info: '#7aa2f7', + successForeground: '#1a1a24', + warningForeground: '#1a1a24', + errorForeground: '#1a1a24', + successDim: 'rgba(102, 217, 160, 0.15)', + warningDim: 'rgba(244, 196, 48, 0.15)', + errorDim: 'rgba(224, 80, 112, 0.15)', + infoDim: 'rgba(122, 162, 247, 0.15)', + diffAddition: '#66d9a0', + diffAdditionBg: 'rgba(102, 217, 160, 0.15)', + diffDeletion: '#e05070', + diffDeletionBg: 'rgba(224, 80, 112, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, 'dre-synth': { @@ -303,17 +541,34 @@ export const THEMES: Record = { colors: { bgMain: '#0d0221', bgSidebar: '#0a0118', - bgActivity: '#150530', - border: '#00d4aa', + bgActivity: '#1a0838', + border: '#1a3a4a', textMain: '#f0e6ff', textDim: '#60e0d0', accent: '#00ffcc', accentDim: 'rgba(0, 255, 204, 0.25)', accentText: '#40ffdd', accentForeground: '#0d0221', - success: '#00ffcc', - warning: '#ff2a6d', + success: '#33ff99', + warning: '#ff9944', error: '#ff2a6d', + info: '#40ffdd', + successForeground: '#0d0221', + warningForeground: '#0d0221', + errorForeground: '#0d0221', + successDim: 'rgba(51, 255, 153, 0.15)', + warningDim: 'rgba(255, 153, 68, 0.15)', + errorDim: 'rgba(255, 42, 109, 0.15)', + infoDim: 'rgba(64, 255, 221, 0.15)', + diffAddition: '#33ff99', + diffAdditionBg: 'rgba(51, 255, 153, 0.15)', + diffDeletion: '#ff2a6d', + diffDeletionBg: 'rgba(255, 42, 109, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, inquest: { @@ -334,6 +589,23 @@ export const THEMES: Record = { success: '#f5f5f5', warning: '#cc0033', error: '#cc0033', + info: '#888888', + successForeground: '#0a0a0a', + warningForeground: '#ffffff', + errorForeground: '#ffffff', + successDim: 'rgba(245, 245, 245, 0.15)', + warningDim: 'rgba(204, 0, 51, 0.15)', + errorDim: 'rgba(204, 0, 51, 0.15)', + infoDim: 'rgba(136, 136, 136, 0.15)', + diffAddition: '#f5f5f5', + diffAdditionBg: 'rgba(245, 245, 245, 0.15)', + diffDeletion: '#cc0033', + diffDeletionBg: 'rgba(204, 0, 51, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, // Custom theme - user-configurable, defaults to Dracula @@ -355,6 +627,23 @@ export const THEMES: Record = { success: '#50fa7b', warning: '#ffb86c', error: '#ff5555', + info: '#8be9fd', + successForeground: '#282a36', + warningForeground: '#282a36', + errorForeground: '#282a36', + successDim: 'rgba(80, 250, 123, 0.15)', + warningDim: 'rgba(255, 184, 108, 0.15)', + errorDim: 'rgba(255, 85, 85, 0.15)', + infoDim: 'rgba(139, 233, 253, 0.15)', + diffAddition: '#50fa7b', + diffAdditionBg: 'rgba(80, 250, 123, 0.15)', + diffDeletion: '#ff5555', + diffDeletionBg: 'rgba(255, 85, 85, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }, }; diff --git a/src/web/components/ThemeProvider.tsx b/src/web/components/ThemeProvider.tsx index 4231255b0..8df3a5cd5 100644 --- a/src/web/components/ThemeProvider.tsx +++ b/src/web/components/ThemeProvider.tsx @@ -52,6 +52,23 @@ const defaultDarkTheme: Theme = { success: '#22c55e', warning: '#eab308', error: '#ef4444', + info: '#6366f1', + successForeground: '#0b0b0d', + warningForeground: '#0b0b0d', + errorForeground: '#0b0b0d', + successDim: 'rgba(34, 197, 94, 0.15)', + warningDim: 'rgba(234, 179, 8, 0.15)', + errorDim: 'rgba(239, 68, 68, 0.15)', + infoDim: 'rgba(99, 102, 241, 0.15)', + diffAddition: '#22c55e', + diffAdditionBg: 'rgba(34, 197, 94, 0.15)', + diffDeletion: '#ef4444', + diffDeletionBg: 'rgba(239, 68, 68, 0.15)', + overlay: 'rgba(0, 0, 0, 0.6)', + overlayHeavy: 'rgba(0, 0, 0, 0.8)', + hoverBg: 'rgba(255, 255, 255, 0.06)', + activeBg: 'rgba(255, 255, 255, 0.15)', + shadow: 'rgba(0, 0, 0, 0.3)', }, }; @@ -77,6 +94,23 @@ const defaultLightTheme: Theme = { success: '#1a7f37', warning: '#9a6700', error: '#cf222e', + info: '#0969da', + successForeground: '#ffffff', + warningForeground: '#ffffff', + errorForeground: '#ffffff', + successDim: 'rgba(26, 127, 55, 0.1)', + warningDim: 'rgba(154, 103, 0, 0.1)', + errorDim: 'rgba(207, 34, 46, 0.1)', + infoDim: 'rgba(9, 105, 218, 0.1)', + diffAddition: '#1a7f37', + diffAdditionBg: 'rgba(26, 127, 55, 0.1)', + diffDeletion: '#cf222e', + diffDeletionBg: 'rgba(207, 34, 46, 0.1)', + overlay: 'rgba(0, 0, 0, 0.5)', + overlayHeavy: 'rgba(0, 0, 0, 0.7)', + hoverBg: 'rgba(0, 0, 0, 0.04)', + activeBg: 'rgba(0, 0, 0, 0.1)', + shadow: 'rgba(0, 0, 0, 0.15)', }, }; diff --git a/src/web/utils/cssCustomProperties.ts b/src/web/utils/cssCustomProperties.ts index 1ffa3b504..c196379c9 100644 --- a/src/web/utils/cssCustomProperties.ts +++ b/src/web/utils/cssCustomProperties.ts @@ -30,6 +30,23 @@ export type ThemeCSSProperty = | '--maestro-success' | '--maestro-warning' | '--maestro-error' + | '--maestro-info' + | '--maestro-success-foreground' + | '--maestro-warning-foreground' + | '--maestro-error-foreground' + | '--maestro-success-dim' + | '--maestro-warning-dim' + | '--maestro-error-dim' + | '--maestro-info-dim' + | '--maestro-diff-addition' + | '--maestro-diff-addition-bg' + | '--maestro-diff-deletion' + | '--maestro-diff-deletion-bg' + | '--maestro-overlay' + | '--maestro-overlay-heavy' + | '--maestro-hover-bg' + | '--maestro-active-bg' + | '--maestro-shadow' | '--maestro-mode'; /** @@ -49,6 +66,23 @@ const colorToCSSProperty: Record = { success: '--maestro-success', warning: '--maestro-warning', error: '--maestro-error', + info: '--maestro-info', + successForeground: '--maestro-success-foreground', + warningForeground: '--maestro-warning-foreground', + errorForeground: '--maestro-error-foreground', + successDim: '--maestro-success-dim', + warningDim: '--maestro-warning-dim', + errorDim: '--maestro-error-dim', + infoDim: '--maestro-info-dim', + diffAddition: '--maestro-diff-addition', + diffAdditionBg: '--maestro-diff-addition-bg', + diffDeletion: '--maestro-diff-deletion', + diffDeletionBg: '--maestro-diff-deletion-bg', + overlay: '--maestro-overlay', + overlayHeavy: '--maestro-overlay-heavy', + hoverBg: '--maestro-hover-bg', + activeBg: '--maestro-active-bg', + shadow: '--maestro-shadow', }; /** @@ -68,6 +102,23 @@ export const THEME_CSS_PROPERTIES: ThemeCSSProperty[] = [ '--maestro-success', '--maestro-warning', '--maestro-error', + '--maestro-info', + '--maestro-success-foreground', + '--maestro-warning-foreground', + '--maestro-error-foreground', + '--maestro-success-dim', + '--maestro-warning-dim', + '--maestro-error-dim', + '--maestro-info-dim', + '--maestro-diff-addition', + '--maestro-diff-addition-bg', + '--maestro-diff-deletion', + '--maestro-diff-deletion-bg', + '--maestro-overlay', + '--maestro-overlay-heavy', + '--maestro-hover-bg', + '--maestro-active-bg', + '--maestro-shadow', '--maestro-mode', ];