From 235f80643e6746503b9a7ba433acdd3fc6fe9b1c Mon Sep 17 00:00:00 2001 From: theblazehen Date: Mon, 15 Dec 2025 18:11:38 +0200 Subject: [PATCH] feat: add UI customization and model management features (#60) * feat: add UI customization and model management features Add comprehensive UI customization options and enhanced model selection: - **Favorite & Recent Models**: Star models for quick access, track 5 most recent - Add favorite/recent sections to model dropdowns - Persist preferences in local storage - Works in ModelControls and ModelSelector components - **Tool Call Expansion Settings**: Control default expansion state for tool outputs - Three modes: collapsed, activity (summary), detailed - Applies to activity groups and individual tool calls - Configurable in Appearance Settings - **Font Size & Spacing Controls**: Adjustable typography and layout density - Font size: 50-200% scaling of all semantic typography - Spacing/padding: 50-200% scaling of margins, gaps, line heights - Real-time preview with reset buttons - Settings in Appearance Settings - **VSCode Extension Settings View**: Full settings access in VSCode extension - Add settings navigation and view type - Settings button in header - Navigate between sessions, chat, and settings Technical changes: - Enhanced useUIStore with new state management - Dynamic CSS variable scaling for typography and spacing - Typography helper functions for variable access - ThemeProvider integration for auto-applying scales * fix: hide theme mode in VSCode and fix detailed tool expansion - Hide "Theme Mode" setting in VSCode extension settings as VSCode dynamically applies its own theme to the extension - Fix bug where tools from subsequent messages in a turn weren't expanded when "Detailed" mode was selected - Now correctly aggregates all tool IDs from turnGroupingContext when calculating effective expanded tools for progressive groups --- .../ui/src/components/chat/ChatMessage.tsx | 47 +- .../ui/src/components/chat/ModelControls.tsx | 410 ++++++++++++++++-- .../components/chat/hooks/useTurnGrouping.ts | 15 +- .../ui/src/components/layout/VSCodeLayout.tsx | 45 +- .../components/providers/ThemeProvider.tsx | 6 +- .../sections/agents/ModelSelector.tsx | 340 ++++++++++++++- .../sections/settings/AppearanceSettings.tsx | 124 +++++- packages/ui/src/constants/sidebar.ts | 9 +- packages/ui/src/hooks/useModelLists.ts | 50 +++ packages/ui/src/index.css | 58 ++- packages/ui/src/lib/typography.ts | 22 + packages/ui/src/stores/useUIStore.ts | 140 ++++++ packages/vscode/webview/App.tsx | 8 +- .../vscode/webview/hooks/useNavigation.ts | 4 +- 14 files changed, 1192 insertions(+), 86 deletions(-) create mode 100644 packages/ui/src/hooks/useModelLists.ts diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index fe173889..f895814d 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -107,6 +107,7 @@ const ChatMessage: React.FC = ({ const providers = useConfigStore((state) => state.providers); const showReasoningTraces = useUIStore((state) => state.showReasoningTraces); + const toolCallExpansion = useUIStore((state) => state.toolCallExpansion); React.useEffect(() => { if (currentSessionId) { @@ -302,9 +303,51 @@ const ChatMessage: React.FC = ({ if (isUser) { return []; } - return visibleParts.filter((part) => part.type === 'tool'); + const filtered = visibleParts.filter((part) => part.type === 'tool'); + return filtered; }, [isUser, visibleParts]); + const effectiveExpandedTools = React.useMemo(() => { + // 'collapsed': Activity and tools start collapsed + // 'activity': Activity expanded, tools collapsed + // 'detailed': Activity and tools expanded + + if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') { + // Tools default collapsed: expandedTools contains IDs of tools that ARE expanded + return expandedTools; + } + + // 'detailed': Tools default expanded + // Collect all relevant tool IDs (from this message and the entire turn if we're rendering a progressive group) + const allToolIds = new Set(); + + // 1. Add tools from this message + for (const part of toolParts) { + if (part.id) { + allToolIds.add(part.id); + } + } + + // 2. If we're rendering a progressive group for the turn, include all turn tools + if (turnGroupingContext?.isFirstAssistantInTurn) { + for (const activity of turnGroupingContext.activityParts) { + if (activity.kind === 'tool' && activity.part.id) { + allToolIds.add(activity.part.id); + } + } + } + + // expandedTools contains IDs of tools that ARE collapsed (inverted) + // Return a set of all tool IDs EXCEPT those in expandedTools + const effective = new Set(); + for (const id of allToolIds) { + if (!expandedTools.has(id)) { + effective.add(id); + } + } + return effective; + }, [toolCallExpansion, expandedTools, toolParts, turnGroupingContext]); + const agentMention = React.useMemo(() => { if (!isUser) { return undefined; @@ -707,7 +750,7 @@ const ChatMessage: React.FC = ({ hasTouchInput={hasTouchInput} copiedCode={copiedCode} onCopyCode={handleCopyCode} - expandedTools={expandedTools} + expandedTools={effectiveExpandedTools} onToggleTool={handleToggleTool} onShowPopup={handleShowPopup} streamPhase={streamPhase} diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index edecdd68..5ede1e7b 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -14,7 +14,10 @@ import { RiPencilAiLine, RiQuestionLine, RiSearchLine, + RiStarFill, + RiStarLine, RiText, + RiTimeLine, RiToolsLine, } from '@remixicon/react'; import type { EditPermissionMode } from '@/stores/types/sessionTypes'; @@ -43,6 +46,8 @@ import { cn } from '@/lib/utils'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useModelLists } from '@/hooks/useModelLists'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type IconComponent = ComponentType; @@ -229,6 +234,8 @@ export const ModelControls: React.FC = ({ className }) => { } = useSessionStore(); const contextHydrated = useContextStore((state) => state.hasHydrated); + const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore(); + const { favoriteModelsList, recentModelsList } = useModelLists(); const { isMobile } = useDeviceInfo(); const isDesktopRuntime = useIsDesktopRuntime(); @@ -769,6 +776,8 @@ export const ModelControls: React.FC = ({ className }) => { } return; } + // Add to recent models on successful selection + addRecentModel(providerId, modelId); if (isCompact) { closeMobilePanel(); } @@ -1147,6 +1156,96 @@ export const ModelControls: React.FC = ({ className }) => { )} + {/* Favorites Section for Mobile */} + {!mobileModelQuery && favoriteModelsList.length > 0 && ( +
+
+ + Favorites +
+
+ {favoriteModelsList.map(({ model, providerID, modelID }) => { + const isSelected = providerID === currentProviderId && modelID === currentModelId; + const metadata = getModelMetadata(providerID, modelID); + + return ( + + ); + })} +
+
+ )} + + {/* Recent Section for Mobile */} + {!mobileModelQuery && recentModelsList.length > 0 && ( +
+
+ + Recent +
+
+ {recentModelsList.map(({ model, providerID, modelID }) => { + const isSelected = providerID === currentProviderId && modelID === currentModelId; + const metadata = getModelMetadata(providerID, modelID); + + return ( + + ); + })} +
+
+ )} + {filteredProviders.map(({ provider, providerModels }) => { if (providerModels.length === 0 && !normalizedQuery.length) { return null; @@ -1191,47 +1290,76 @@ export const ModelControls: React.FC = ({ className }) => { const inputIcons = getModalityIcons(metadata, 'input'); return ( - + + aria-label={isFavoriteModel(provider.id as string, model.id as string) ? "Unfavorite" : "Favorite"} + title={isFavoriteModel(provider.id as string, model.id as string) ? "Remove from favorites" : "Add to favorites"} + > + {isFavoriteModel(provider.id as string, model.id as string) ? ( + + ) : ( + + )} + + ); })} @@ -1503,6 +1631,190 @@ export const ModelControls: React.FC = ({ className }) => { + {/* Favorites Section */} + {favoriteModelsList.length > 0 && ( + + + + Favorites + + + + {favoriteModelsList.map(({ model, providerID, modelID }) => { + const metadata = getModelMetadata(providerID, modelID); + const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({ + ...icon, + id: `cap-${icon.key}`, + })); + const modalityIcons = [ + ...getModalityIcons(metadata, 'input'), + ...getModalityIcons(metadata, 'output'), + ]; + const uniqueModalityIcons = Array.from( + new Map(modalityIcons.map((icon) => [icon.key, icon])).values() + ).map((icon) => ({ ...icon, id: `mod-${icon.key}` })); + const indicatorIcons = [...capabilityIcons, ...uniqueModalityIcons]; + const contextTokens = formatTokens(metadata?.limit?.context); + const outputTokens = formatTokens(metadata?.limit?.output); + + return ( + { + e.preventDefault(); + handleProviderAndModelChange(providerID, modelID); + }} + > +
+
+ + {getModelDisplayName(model)} + + {metadata?.limit?.context || metadata?.limit?.output ? ( + + {metadata?.limit?.context ? `${contextTokens} ctx` : ''} + {metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''} + {metadata?.limit?.output ? `${outputTokens} out` : ''} + + ) : null} +
+
+ {indicatorIcons.map(({ id, icon: Icon, label }) => ( + + + + ))} + +
+
+
+ ); + })} +
+
+
+ )} + + {/* Recents Section */} + {recentModelsList.length > 0 && ( + + + + Recent + + + + {recentModelsList.map(({ model, providerID, modelID }) => { + const metadata = getModelMetadata(providerID, modelID); + const capabilityIcons = getCapabilityIcons(metadata).map((icon) => ({ + ...icon, + id: `cap-${icon.key}`, + })); + const modalityIcons = [ + ...getModalityIcons(metadata, 'input'), + ...getModalityIcons(metadata, 'output'), + ]; + const uniqueModalityIcons = Array.from( + new Map(modalityIcons.map((icon) => [icon.key, icon])).values() + ).map((icon) => ({ ...icon, id: `mod-${icon.key}` })); + const indicatorIcons = [...capabilityIcons, ...uniqueModalityIcons]; + const contextTokens = formatTokens(metadata?.limit?.context); + const outputTokens = formatTokens(metadata?.limit?.output); + + return ( + { + e.preventDefault(); + handleProviderAndModelChange(providerID, modelID); + }} + > +
+
+ + {getModelDisplayName(model)} + + {metadata?.limit?.context || metadata?.limit?.output ? ( + + {metadata?.limit?.context ? `${contextTokens} ctx` : ''} + {metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''} + {metadata?.limit?.output ? `${outputTokens} out` : ''} + + ) : null} +
+
+ {indicatorIcons.map(({ id, icon: Icon, label }) => ( + + + + ))} + +
+
+
+ ); + })} +
+
+
+ )} + + {/* Separator before providers */} + {(favoriteModelsList.length > 0 || recentModelsList.length > 0) && ( + + )} + + {/* All Providers Section */} {providers.map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; @@ -1561,13 +1873,14 @@ export const ModelControls: React.FC = ({ className }) => { { + onSelect={(e) => { + e.preventDefault(); handleProviderAndModelChange(provider.id as string, model.id as string); }} > -
-
- +
+
+ {getModelDisplayName(model)} {metadata?.limit?.context || metadata?.limit?.output ? ( @@ -1578,7 +1891,7 @@ export const ModelControls: React.FC = ({ className }) => { ) : null}
-
+
{indicatorIcons.map(({ id, icon: Icon, label }) => ( = ({ className }) => { ))} +
diff --git a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts index fe1b904d..01ac6794 100644 --- a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts +++ b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts @@ -1,6 +1,7 @@ import React from 'react'; import type { Message, Part } from '@opencode-ai/sdk'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; +import { useUIStore } from '@/stores/useUIStore'; export interface ChatMessageEntry { info: Message; @@ -302,30 +303,34 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe () => new Map() ); + const toolCallExpansion = useUIStore((state) => state.toolCallExpansion); + // Activity group is expanded for 'activity' and 'detailed', collapsed for 'collapsed' + const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed'; + const getOrCreateTurnState = React.useCallback( (turnId: string): TurnUiState => { const existing = turnUiStates.get(turnId); if (existing) return existing; - return { isExpanded: false, previewedPartIds: new Set() }; + return { isExpanded: defaultActivityExpanded, previewedPartIds: new Set() }; }, - [turnUiStates] + [turnUiStates, defaultActivityExpanded] ); const toggleGroup = React.useCallback((turnId: string) => { setTurnUiStates((prev) => { const next = new Map(prev); - const current = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set() }; + const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set() }; next.set(turnId, { ...current, isExpanded: !current.isExpanded }); return next; }); - }, []); + }, [defaultActivityExpanded]); const markPartsPreviewedInternal = React.useCallback((turnId: string, partIds: string[]) => { if (partIds.length === 0) return; setTurnUiStates((prev) => { const next = new Map(prev); - const state = next.get(turnId) ?? { isExpanded: false, previewedPartIds: new Set() }; + const state = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set() }; const newPreviewed = new Set(state.previewedPartIds); partIds.forEach((id) => { if (id && id.trim().length > 0) { diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index a80dddb5..2480041a 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -5,10 +5,11 @@ import { ChatView } from '@/components/views'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; -import { RiAddLine, RiArrowLeftLine } from '@remixicon/react'; +import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react'; import { RiLoader4Line } from '@remixicon/react'; +import { SettingsPage } from '@/components/sections/settings/SettingsPage'; -type VSCodeView = 'sessions' | 'chat'; +type VSCodeView = 'sessions' | 'chat' | 'settings'; export const VSCodeLayout: React.FC = () => { const [currentView, setCurrentView] = React.useState('sessions'); @@ -200,7 +201,11 @@ export const VSCodeLayout: React.FC = () => {
{currentView === 'sessions' ? (
- + setCurrentView('settings')} + />
{ />
+ ) : currentView === 'settings' ? ( +
+ +
+ +
+
) : (
void; onNewSession?: () => void; + onSettings?: () => void; showContextUsage?: boolean; } -const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, showContextUsage }) => { +const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, onSettings, showContextUsage }) => { const { getCurrentModel } = useConfigStore(); const getContextUsage = useSessionStore((state) => state.getContextUsage); @@ -285,6 +302,15 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on )} + {onSettings && ( + + )} {showContextUsage && contextUsage && contextUsage.totalTokens > 0 && ( = ({ title, showBack, onBack, on
); }; + +const VSCodeSettingsView: React.FC = () => { + return ( +
+ {/* Content */} +
+ +
+
+ ); +}; diff --git a/packages/ui/src/components/providers/ThemeProvider.tsx b/packages/ui/src/components/providers/ThemeProvider.tsx index 20ae3e2c..d4f880c9 100644 --- a/packages/ui/src/components/providers/ThemeProvider.tsx +++ b/packages/ui/src/components/providers/ThemeProvider.tsx @@ -6,12 +6,14 @@ interface ThemeProviderProps { } export const ThemeProvider: React.FC = ({ children }) => { - const { theme, applyTheme } = useUIStore(); + const { theme, applyTheme, fontSize, applyTypography, padding, applyPadding } = useUIStore(); React.useEffect(() => { applyTheme(); - }, [theme, applyTheme]); + applyTypography(); + applyPadding(); + }, [theme, applyTheme, fontSize, applyTypography, padding, applyPadding]); React.useEffect(() => { diff --git a/packages/ui/src/components/sections/agents/ModelSelector.tsx b/packages/ui/src/components/sections/agents/ModelSelector.tsx index a6ba8f02..e058ae98 100644 --- a/packages/ui/src/components/sections/agents/ModelSelector.tsx +++ b/packages/ui/src/components/sections/agents/ModelSelector.tsx @@ -3,6 +3,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -11,11 +12,12 @@ import { import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; -import { RiArrowDownSLine, RiArrowRightSLine, RiPencilAiLine } from '@remixicon/react'; +import { RiArrowDownSLine, RiArrowRightSLine, RiPencilAiLine, RiStarFill, RiStarLine, RiTimeLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useModelLists } from '@/hooks/useModelLists'; type ProviderModel = Record & { id?: string; name?: string }; @@ -34,6 +36,8 @@ export const ModelSelector: React.FC = ({ }) => { const { providers, modelsMetadata } = useConfigStore(); const isMobile = useUIStore(state => state.isMobile); + const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore(); + const { favoriteModelsList, recentModelsList } = useModelLists(); const { isMobile: deviceIsMobile } = useDeviceInfo(); const isActuallyMobile = isMobile || deviceIsMobile; @@ -53,6 +57,8 @@ export const ModelSelector: React.FC = ({ }); }; + + const getModelDisplayName = (model: Record) => { const name = model?.name || model?.id || ''; const nameStr = String(name); @@ -69,6 +75,8 @@ export const ModelSelector: React.FC = ({ const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => { onChange(newProviderId, newModelId); + // Add to recent models on successful selection + addRecentModel(newProviderId, newModelId); }; const renderMobileModelPanel = () => { @@ -81,6 +89,126 @@ export const ModelSelector: React.FC = ({ title="Select Model" >
+ {/* Favorites Section for Mobile */} + {favoriteModelsList.length > 0 && ( +
+
+ Favorites +
+
+ {favoriteModelsList.map(({ model, providerID, modelID }) => { + const isSelectedModel = providerID === providerId && modelID === modelId; + const metadata = getModelMetadata(providerID, modelID); + + return ( +
+ + + +
+ ); + })} +
+
+ )} + + {/* Recents Section for Mobile */} + {recentModelsList.length > 0 && ( +
+
+ Recents +
+
+ {recentModelsList.map(({ model, providerID, modelID }) => { + const isSelectedModel = providerID === providerId && modelID === modelId; + const metadata = getModelMetadata(providerID, modelID); + + return ( +
+ + + +
+ ); + })} +
+
+ )} + {providers.map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; if (providerModels.length === 0) return null; @@ -121,31 +249,57 @@ export const ModelSelector: React.FC = ({ const metadata = getModelMetadata(provider.id as string, modelItem.id as string); return ( - + +
+ + + {isSelectedModel && ( +
+ )}
- {isSelectedModel && ( -
- )} - +
); })}
@@ -220,6 +374,125 @@ export const ModelSelector: React.FC = ({
+ {/* Favorites Section */} + {favoriteModelsList.length > 0 && ( + + + + Favorites + + + + {favoriteModelsList.map(({ model, providerID, modelID }) => { + const metadata = getModelMetadata(providerID, modelID); + return ( + { + e.preventDefault(); + handleProviderAndModelChange(providerID, modelID); + }} + > +
+
+ {getModelDisplayName(model)} + {typeof (metadata as unknown as Record)?.description === 'string' && ( + + {(metadata as unknown as Record).description as React.ReactNode} + + )} +
+ +
+
+ ); + })} +
+
+
+ )} + + {/* Recents Section */} + {recentModelsList.length > 0 && ( + + + + Recent + + + + {recentModelsList.map(({ model, providerID, modelID }) => { + const metadata = getModelMetadata(providerID, modelID); + return ( + { + e.preventDefault(); + handleProviderAndModelChange(providerID, modelID); + }} + > +
+
+ {getModelDisplayName(model)} + {typeof (metadata as unknown as Record)?.description === 'string' && ( + + {(metadata as unknown as Record).description as React.ReactNode} + + )} +
+ +
+
+ ); + })} +
+
+
+ )} + + {/* Separator before providers */} + {(favoriteModelsList.length > 0 || recentModelsList.length > 0) && ( + + )} + {providers.map((provider) => { const providerModels = Array.isArray(provider.models) ? provider.models : []; @@ -265,17 +538,40 @@ export const ModelSelector: React.FC = ({ { + onSelect={(e) => { + e.preventDefault(); handleProviderAndModelChange(provider.id as string, modelItem.id as string); }} > -
- {getModelDisplayName(modelItem)} - {typeof (metadata as unknown as Record)?.description === 'string' && ( - - {(metadata as unknown as Record).description as React.ReactNode} - - )} +
+
+ {getModelDisplayName(modelItem)} + {typeof (metadata as unknown as Record)?.description === 'string' && ( + + {(metadata as unknown as Record).description as React.ReactNode} + + )} +
+
); diff --git a/packages/ui/src/components/sections/settings/AppearanceSettings.tsx b/packages/ui/src/components/sections/settings/AppearanceSettings.tsx index a204ab0b..296ddaf4 100644 --- a/packages/ui/src/components/sections/settings/AppearanceSettings.tsx +++ b/packages/ui/src/components/sections/settings/AppearanceSettings.tsx @@ -4,6 +4,7 @@ import type { ThemeMode } from '@/types/theme'; import { useUIStore } from '@/stores/useUIStore'; import { cn } from '@/lib/utils'; import { ButtonSmall } from '@/components/ui/button-small'; +import { isVSCodeRuntime } from '@/lib/desktop'; interface Option { id: T; @@ -26,6 +27,12 @@ const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [ }, ]; +const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed'; label: string; description: string }> = [ + { value: 'collapsed', label: 'Collapsed', description: 'Activity and tools start collapsed' }, + { value: 'activity', label: 'Summary', description: 'Activity expanded, tools collapsed' }, + { value: 'detailed', label: 'Detailed', description: 'Activity and tools expanded' }, +]; + const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [ { id: 'dynamic', @@ -47,6 +54,12 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [ export const AppearanceSettings: React.FC = () => { const showReasoningTraces = useUIStore(state => state.showReasoningTraces); const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces); + const toolCallExpansion = useUIStore(state => state.toolCallExpansion); + const setToolCallExpansion = useUIStore(state => state.setToolCallExpansion); + const fontSize = useUIStore(state => state.fontSize); + const setFontSize = useUIStore(state => state.setFontSize); + const padding = useUIStore(state => state.padding); + const setPadding = useUIStore(state => state.setPadding); const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference); const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference); const { @@ -56,22 +69,119 @@ export const AppearanceSettings: React.FC = () => { return (
- {} + {!isVSCodeRuntime() && ( +
+
+

+ Theme Mode +

+
+ +
+ {THEME_MODE_OPTIONS.map((option) => ( + setThemeMode(option.value)} + > + {option.label} + + ))} +
+
+ )} +

- Theme Mode + Font Size

+

+ {fontSize}% of default size +

+
+ setFontSize(Number(e.target.value))} + className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" + /> + setFontSize(Number(e.target.value))} + className="w-20 px-2 py-1 text-center border border-border rounded bg-background text-foreground typography-ui-label" + /> + +
+
- {} +
+
+

+ Spacing +

+

+ {padding}% of default spacing +

+
+
+ setPadding(Number(e.target.value))} + className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" + /> + setPadding(Number(e.target.value))} + className="w-20 px-2 py-1 text-center border border-border rounded bg-background text-foreground typography-ui-label" + /> + +
+
+ +
+
+

+ Default Tool Output +

+

+ {TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description} +

+
- {THEME_MODE_OPTIONS.map((option) => ( + {TOOL_EXPANSION_OPTIONS.map((option) => ( setThemeMode(option.value)} + variant={toolCallExpansion === option.value ? 'default' : 'outline'} + className={cn(toolCallExpansion === option.value ? undefined : 'text-foreground')} + onClick={() => setToolCallExpansion(option.value)} > {option.label} diff --git a/packages/ui/src/constants/sidebar.ts b/packages/ui/src/constants/sidebar.ts index c4a47279..34f7424a 100644 --- a/packages/ui/src/constants/sidebar.ts +++ b/packages/ui/src/constants/sidebar.ts @@ -1,4 +1,4 @@ -import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiPaintBrushLine } from '@remixicon/react'; +import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiPaintBrushLine, RiStackLine } from '@remixicon/react'; import type { ComponentType } from 'react'; export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'providers' | 'git-identities' | 'settings'; @@ -32,7 +32,12 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [ description: 'Create and maintain custom slash commands for OpenCode.', icon: RiCommandLine, }, - + { + id: 'providers', + label: 'Providers', + description: 'Configure AI model providers and API credentials.', + icon: RiStackLine, + }, { id: 'git-identities', label: 'Git Identities', diff --git a/packages/ui/src/hooks/useModelLists.ts b/packages/ui/src/hooks/useModelLists.ts new file mode 100644 index 00000000..0e2f0ee9 --- /dev/null +++ b/packages/ui/src/hooks/useModelLists.ts @@ -0,0 +1,50 @@ +import React from 'react'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import type { Provider } from '@opencode-ai/sdk'; + +type ProviderModel = Provider["models"][string]; +type ProviderWithModelList = Omit & { models: ProviderModel[] }; + +export interface ModelListItem { + provider: ProviderWithModelList; + model: ProviderModel; + providerID: string; + modelID: string; +} + +export const useModelLists = () => { + const { providers } = useConfigStore(); + const { favoriteModels, recentModels } = useUIStore(); + + const favoriteModelsList = React.useMemo(() => { + return favoriteModels + .map(({ providerID, modelID }) => { + const provider = providers.find((p) => p.id === providerID); + if (!provider) return null; + const providerModels = Array.isArray(provider.models) ? provider.models : []; + const model = providerModels.find((m: ProviderModel) => m.id === modelID); + if (!model) return null; + return { provider, model, providerID, modelID }; + }) + .filter((item): item is ModelListItem => item !== null); + }, [favoriteModels, providers]); + + const recentModelsList = React.useMemo(() => { + return recentModels + .map(({ providerID, modelID }) => { + const provider = providers.find((p) => p.id === providerID); + if (!provider) return null; + const providerModels = Array.isArray(provider.models) ? provider.models : []; + const model = providerModels.find((m: ProviderModel) => m.id === modelID); + if (!model) return null; + return { provider, model, providerID, modelID }; + }) + .filter((item): item is ModelListItem => item !== null) + .filter(({ providerID, modelID }) => + !favoriteModels.some(fav => fav.providerID === providerID && fav.modelID === modelID) + ); + }, [recentModels, providers, favoriteModels]); + + return { favoriteModelsList, recentModelsList }; +}; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index d61af43c..a8f50a73 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -11,6 +11,7 @@ --ui-regular-font-weight: 400; --oc-scrollbar-thumb: oklch(0.32 0.03 50 / 0.4); --oc-scrollbar-thumb-hover: oklch(0.32 0.03 50 / 0.6); + --padding-scale: 1; } @custom-variant dark (&:is(.dark *)); @@ -224,15 +225,31 @@ svg.animate-spin { .chat-column { width: min(100%, 56rem); margin-inline: auto; - padding-inline: clamp(1rem, 3vw, 1.5rem); + padding-inline: calc(clamp(1rem, 3vw, 1.5rem) * var(--padding-scale, 1)); } @media (min-width: 1024px) { .chat-column { - padding-inline: clamp(1.25rem, 2.5vw, 2rem); + padding-inline: calc(clamp(1.25rem, 2.5vw, 2rem) * var(--padding-scale, 1)); } } + /* Enhanced focus indicators for small interactive elements */ + .model-favorite-button { + position: relative; + transition: transform 0.1s ease; + } + + .model-favorite-button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + border-radius: 0.25rem; + } + + .model-favorite-button:active { + transform: scale(0.95); + } + /* FadeInOnReveal handles all animations - no custom CSS needed */ /* Heading typography - all use markdown size, differentiated by weight/color */ @@ -410,6 +427,43 @@ body { --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + + /* Override Tailwind spacing scale with custom properties that respect --padding-scale */ + --spacing-0: 0; + --spacing-0\.5: calc(0.125rem * var(--padding-scale, 1)); + --spacing-1: calc(0.25rem * var(--padding-scale, 1)); + --spacing-1\.5: calc(0.375rem * var(--padding-scale, 1)); + --spacing-2: calc(0.5rem * var(--padding-scale, 1)); + --spacing-2\.5: calc(0.625rem * var(--padding-scale, 1)); + --spacing-3: calc(0.75rem * var(--padding-scale, 1)); + --spacing-3\.5: calc(0.875rem * var(--padding-scale, 1)); + --spacing-4: calc(1rem * var(--padding-scale, 1)); + --spacing-5: calc(1.25rem * var(--padding-scale, 1)); + --spacing-6: calc(1.5rem * var(--padding-scale, 1)); + --spacing-7: calc(1.75rem * var(--padding-scale, 1)); + --spacing-8: calc(2rem * var(--padding-scale, 1)); + --spacing-9: calc(2.25rem * var(--padding-scale, 1)); + --spacing-10: calc(2.5rem * var(--padding-scale, 1)); + --spacing-11: calc(2.75rem * var(--padding-scale, 1)); + --spacing-12: calc(3rem * var(--padding-scale, 1)); + --spacing-14: calc(3.5rem * var(--padding-scale, 1)); + --spacing-16: calc(4rem * var(--padding-scale, 1)); + --spacing-20: calc(5rem * var(--padding-scale, 1)); + --spacing-24: calc(6rem * var(--padding-scale, 1)); + --spacing-28: calc(7rem * var(--padding-scale, 1)); + --spacing-32: calc(8rem * var(--padding-scale, 1)); + --spacing-36: calc(9rem * var(--padding-scale, 1)); + --spacing-40: calc(10rem * var(--padding-scale, 1)); + --spacing-44: calc(11rem * var(--padding-scale, 1)); + --spacing-48: calc(12rem * var(--padding-scale, 1)); + --spacing-52: calc(13rem * var(--padding-scale, 1)); + --spacing-56: calc(14rem * var(--padding-scale, 1)); + --spacing-60: calc(15rem * var(--padding-scale, 1)); + --spacing-64: calc(16rem * var(--padding-scale, 1)); + --spacing-72: calc(18rem * var(--padding-scale, 1)); + --spacing-80: calc(20rem * var(--padding-scale, 1)); + --spacing-96: calc(24rem * var(--padding-scale, 1)); + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); diff --git a/packages/ui/src/lib/typography.ts b/packages/ui/src/lib/typography.ts index 90b052ff..a5b7dbcd 100644 --- a/packages/ui/src/lib/typography.ts +++ b/packages/ui/src/lib/typography.ts @@ -7,6 +7,28 @@ export const SEMANTIC_TYPOGRAPHY = { micro: '0.875rem', } as const; +export const FONT_SIZE_SCALES = { + small: { + markdown: '0.875rem', + code: '0.8125rem', + uiHeader: '0.875rem', + uiLabel: '0.8125rem', + meta: '0.8125rem', + micro: '0.75rem', + }, + medium: SEMANTIC_TYPOGRAPHY, + large: { + markdown: '1rem', + code: '0.9375rem', + uiHeader: '1rem', + uiLabel: '0.9375rem', + meta: '0.9375rem', + micro: '0.9375rem', + }, +} as const; + +export type FontSizeOption = keyof typeof FONT_SIZE_SCALES; + export const VSCODE_TYPOGRAPHY = { // Keep VS Code webview typography slightly tighter; VS Code UI chrome already provides density. markdown: '0.9063rem', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 524145d6..4c0ff831 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import type { SidebarSection } from '@/constants/sidebar'; import { getSafeStorage } from './utils/safeStorage'; +import { getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography'; export type MainTab = 'chat' | 'git' | 'diff' | 'terminal'; export type EventStreamStatus = @@ -32,6 +33,13 @@ interface UIStore { eventStreamHint: string | null; showReasoningTraces: boolean; + toolCallExpansion: 'collapsed' | 'activity' | 'detailed'; + fontSize: number; + padding: number; + + favoriteModels: Array<{ providerID: string; modelID: string }>; + recentModels: Array<{ providerID: string; modelID: string }>; + diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffFileLayout: Record; diffWrapLines: boolean; @@ -56,7 +64,15 @@ interface UIStore { setSidebarSection: (section: SidebarSection) => void; setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void; setShowReasoningTraces: (value: boolean) => void; + setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void; + setFontSize: (size: number) => void; + setPadding: (size: number) => void; + applyTypography: () => void; + applyPadding: () => void; updateProportionalSidebarWidths: () => void; + toggleFavoriteModel: (providerID: string, modelID: string) => void; + isFavoriteModel: (providerID: string, modelID: string) => boolean; + addRecentModel: (providerID: string, modelID: string) => void; setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void; setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void; setDiffWrapLines: (wrap: boolean) => void; @@ -83,6 +99,11 @@ export const useUIStore = create()( eventStreamStatus: 'idle', eventStreamHint: null, showReasoningTraces: false, + toolCallExpansion: 'collapsed', + fontSize: 100, + padding: 100, + favoriteModels: [], + recentModels: [], diffLayoutPreference: 'dynamic', diffFileLayout: {}, diffWrapLines: false, @@ -194,6 +215,78 @@ export const useUIStore = create()( set({ showReasoningTraces: value }); }, + setToolCallExpansion: (value) => { + set({ toolCallExpansion: value }); + }, + + setFontSize: (size) => { + // Clamp between 50% and 200% + const clampedSize = Math.max(50, Math.min(200, size)); + set({ fontSize: clampedSize }); + get().applyTypography(); + }, + + setPadding: (size) => { + // Clamp between 50% and 200% + const clampedSize = Math.max(50, Math.min(200, size)); + set({ padding: clampedSize }); + get().applyPadding(); + }, + + applyTypography: () => { + const { fontSize } = get(); + const root = document.documentElement; + + // Apply font size as a percentage scale + // 100 = default (1.0x), 50 = half size (0.5x), 200 = double (2.0x) + const scale = fontSize / 100; + + // Store scale for reference + root.style.setProperty('--font-scale', scale.toString()); + + // Read base values from SEMANTIC_TYPOGRAPHY or use defaults + const baseValues: Record = { + markdown: '0.9375rem', + code: '0.9063rem', + uiHeader: '0.9375rem', + uiLabel: '0.875rem', + meta: '0.875rem', + micro: '0.875rem', + }; + + // Apply scaled values to each typography variable + Object.entries(baseValues).forEach(([key, baseValue]) => { + const cssVar = getTypographyVariable(key as SemanticTypographyKey); + const numericValue = parseFloat(baseValue); + if (!isNaN(numericValue)) { + root.style.setProperty(cssVar, `${numericValue * scale}rem`); + } + }); + }, + + applyPadding: () => { + const { padding } = get(); + const root = document.documentElement; + + // Apply padding as a percentage scale with non-linear scaling + // Use square root for more natural scaling at extremes + const scale = padding / 100; + const adjustedScale = Math.sqrt(scale); + + // Set the CSS custom property that all spacing tokens reference + root.style.setProperty('--padding-scale', adjustedScale.toString()); + + // Apply line height scaling - use much smaller scale factor + // Line height should remain relatively constant even when font size changes + // Use a dampened scale: 50% font = 0.9x line-height, 200% font = 1.1x line-height + const lineHeightScale = 1 + (scale - 1) * 0.15; // Reduces impact: 50% -> 0.925, 200% -> 1.15 + + root.style.setProperty('--line-height-tight', (1.25 * lineHeightScale).toFixed(3)); + root.style.setProperty('--line-height-normal', (1.5 * lineHeightScale).toFixed(3)); + root.style.setProperty('--line-height-relaxed', (1.625 * lineHeightScale).toFixed(3)); + root.style.setProperty('--line-height-loose', (2 * lineHeightScale).toFixed(3)); + }, + setDiffLayoutPreference: (mode) => { set({ diffLayoutPreference: mode }); }, @@ -211,6 +304,48 @@ export const useUIStore = create()( set({ diffWrapLines: wrap }); }, + toggleFavoriteModel: (providerID, modelID) => { + set((state) => { + const exists = state.favoriteModels.some( + (fav) => fav.providerID === providerID && fav.modelID === modelID + ); + + if (exists) { + // Remove from favorites + return { + favoriteModels: state.favoriteModels.filter( + (fav) => !(fav.providerID === providerID && fav.modelID === modelID) + ), + }; + } else { + // Add to favorites (newest first) + return { + favoriteModels: [{ providerID, modelID }, ...state.favoriteModels], + }; + } + }); + }, + + isFavoriteModel: (providerID, modelID) => { + const { favoriteModels } = get(); + return favoriteModels.some( + (fav) => fav.providerID === providerID && fav.modelID === modelID + ); + }, + + addRecentModel: (providerID, modelID) => { + set((state) => { + // Remove existing instance if any + const filtered = state.recentModels.filter( + (m) => !(m.providerID === providerID && m.modelID === modelID) + ); + // Add to front, limit to 5 + return { + recentModels: [{ providerID, modelID }, ...filtered].slice(0, 5), + }; + }); + }, + updateProportionalSidebarWidths: () => { if (typeof window === 'undefined') { return; @@ -254,6 +389,11 @@ export const useUIStore = create()( isSessionCreateDialogOpen: state.isSessionCreateDialogOpen, isSettingsDialogOpen: state.isSettingsDialogOpen, showReasoningTraces: state.showReasoningTraces, + toolCallExpansion: state.toolCallExpansion, + fontSize: state.fontSize, + padding: state.padding, + favoriteModels: state.favoriteModels, + recentModels: state.recentModels, diffLayoutPreference: state.diffLayoutPreference, diffWrapLines: state.diffWrapLines, }) diff --git a/packages/vscode/webview/App.tsx b/packages/vscode/webview/App.tsx index 102979a7..109b6bc9 100644 --- a/packages/vscode/webview/App.tsx +++ b/packages/vscode/webview/App.tsx @@ -263,7 +263,13 @@ export function VSCodeApp() {
- {currentView === 'sessions' ? : } + {currentView === 'sessions' ? ( + + ) : currentView === 'settings' ? ( + + ) : ( + + )}
); diff --git a/packages/vscode/webview/hooks/useNavigation.ts b/packages/vscode/webview/hooks/useNavigation.ts index 9bc45102..f2c722ed 100644 --- a/packages/vscode/webview/hooks/useNavigation.ts +++ b/packages/vscode/webview/hooks/useNavigation.ts @@ -1,12 +1,13 @@ import { create } from 'zustand'; -export type ViewType = 'sessions' | 'chat'; +export type ViewType = 'sessions' | 'chat' | 'settings'; interface NavigationState { currentView: ViewType; navigateTo: (view: ViewType) => void; goToChat: () => void; goToSessions: () => void; + goToSettings: () => void; } export const useNavigation = create((set) => ({ @@ -14,4 +15,5 @@ export const useNavigation = create((set) => ({ navigateTo: (view) => set({ currentView: view }), goToChat: () => set({ currentView: 'chat' }), goToSessions: () => set({ currentView: 'sessions' }), + goToSettings: () => set({ currentView: 'settings' }), }));