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
This commit is contained in:
theblazehen
2025-12-15 18:11:38 +02:00
committed by GitHub
parent 42e25d03f3
commit 235f80643e
14 changed files with 1192 additions and 86 deletions
@@ -107,6 +107,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
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<ChatMessageProps> = ({
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<string>();
// 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<string>();
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<ChatMessageProps> = ({
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
expandedTools={effectiveExpandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
+372 -38
View File
@@ -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<any>;
@@ -229,6 +234,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ 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<ModelControlsProps> = ({ className }) => {
}
return;
}
// Add to recent models on successful selection
addRecentModel(providerId, modelId);
if (isCompact) {
closeMobilePanel();
}
@@ -1147,6 +1156,96 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
</div>
)}
{/* Favorites Section for Mobile */}
{!mobileModelQuery && favoriteModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiStarFill className="h-3 w-3 inline-block mr-1.5 text-yellow-500" />
Favorites
</div>
<div className="flex flex-col border-t border-border/30">
{favoriteModelsList.map(({ model, providerID, modelID }) => {
const isSelected = providerID === currentProviderId && modelID === currentModelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<button
key={`fav-mobile-${providerID}-${modelID}`}
type="button"
onClick={() => handleProviderAndModelChange(providerID, modelID)}
className={cn(
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-accent/40'
)}
>
<div className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta font-medium text-foreground truncate">
{getModelDisplayName(model)}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="typography-micro text-muted-foreground whitespace-nowrap">
{metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
{metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Recent Section for Mobile */}
{!mobileModelQuery && recentModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
<RiTimeLine className="h-3 w-3 inline-block mr-1.5" />
Recent
</div>
<div className="flex flex-col border-t border-border/30">
{recentModelsList.map(({ model, providerID, modelID }) => {
const isSelected = providerID === currentProviderId && modelID === currentModelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<button
key={`recent-mobile-${providerID}-${modelID}`}
type="button"
onClick={() => handleProviderAndModelChange(providerID, modelID)}
className={cn(
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
isSelected ? 'bg-primary/15 text-primary' : 'hover:bg-accent/40'
)}
>
<div className="flex items-center gap-2 min-w-0">
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="typography-meta font-medium text-foreground truncate">
{getModelDisplayName(model)}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="typography-micro text-muted-foreground whitespace-nowrap">
{metadata?.limit?.context ? `${formatTokens(metadata?.limit?.context)} ctx` : ''}
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
{metadata?.limit?.output ? `${formatTokens(metadata?.limit?.output)} out` : ''}
</div>
)}
</div>
</button>
);
})}
</div>
</div>
)}
{filteredProviders.map(({ provider, providerModels }) => {
if (providerModels.length === 0 && !normalizedQuery.length) {
return null;
@@ -1191,47 +1290,76 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const inputIcons = getModalityIcons(metadata, 'input');
return (
<button
<div
key={model.id}
type="button"
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
className={cn(
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 text-left last:border-b-0',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
'flex w-full items-start gap-2 border-b border-border/30 px-2 py-1.5 last:border-b-0',
isSelected
? 'bg-primary/15 text-primary'
: 'hover:bg-accent/40'
: ''
)}
>
<div className="flex min-w-0 flex-col">
<span className="typography-meta font-medium text-foreground">
{getModelDisplayName(model)}
</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 text-right">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="flex items-center gap-1 typography-micro text-muted-foreground">
{metadata?.limit?.context ? <span>{formatTokens(metadata?.limit?.context)} ctx</span> : null}
{metadata?.limit?.context && metadata?.limit?.output ? <span></span> : null}
{metadata?.limit?.output ? <span>{formatTokens(metadata?.limit?.output)} out</span> : null}
</div>
<button
type="button"
onClick={() => handleProviderAndModelChange(provider.id as string, model.id as string)}
className={cn(
'flex flex-1 min-w-0 items-start gap-2 text-left',
'focus:outline-none focus-visible:ring-1 focus-visible:ring-primary',
!isSelected && 'hover:bg-accent/40'
)}
{(capabilityIcons.length > 0 || inputIcons.length > 0) && (
<div className="flex items-center justify-end gap-1">
{[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => (
<span
key={`meta-${provider.id}-${model.id}-${key}`}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
title={label}
aria-label={label}
>
<IconComponent className="h-3 w-3" />
</span>
))}
</div>
>
<div className="flex min-w-0 flex-col">
<span className="typography-meta font-medium text-foreground">
{getModelDisplayName(model)}
</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 text-right">
{(metadata?.limit?.context || metadata?.limit?.output) && (
<div className="flex items-center gap-1 typography-micro text-muted-foreground">
{metadata?.limit?.context ? <span>{formatTokens(metadata?.limit?.context)} ctx</span> : null}
{metadata?.limit?.context && metadata?.limit?.output ? <span></span> : null}
{metadata?.limit?.output ? <span>{formatTokens(metadata?.limit?.output)} out</span> : null}
</div>
)}
{(capabilityIcons.length > 0 || inputIcons.length > 0) && (
<div className="flex items-center justify-end gap-1">
{[...capabilityIcons, ...inputIcons].map(({ key, icon: IconComponent, label }) => (
<span
key={`meta-${provider.id}-${model.id}-${key}`}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
title={label}
aria-label={label}
>
<IconComponent className="h-3 w-3" />
</span>
))}
</div>
)}
</div>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(provider.id as string, model.id as string);
}}
className={cn(
"model-favorite-button flex h-5 w-5 items-center justify-center hover:text-yellow-600 flex-shrink-0",
isFavoriteModel(provider.id as string, model.id as string)
? "text-yellow-500"
: "text-muted-foreground"
)}
</div>
</button>
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) ? (
<RiStarFill className="h-4 w-4" />
) : (
<RiStarLine className="h-4 w-4" />
)}
</button>
</div>
);
})}
</div>
@@ -1503,6 +1631,190 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent className="max-w-[300px]">
{/* Favorites Section */}
{favoriteModelsList.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="typography-meta">
<RiStarFill className="h-3 w-3 flex-shrink-0 mr-2 text-yellow-500" />
Favorites
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
>
{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 (
<DropdownMenuItem
key={`fav-${providerID}-${modelID}`}
className="typography-meta"
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID);
}}
>
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
{metadata?.limit?.context || metadata?.limit?.output ? (
<span className="typography-meta text-muted-foreground">
{metadata?.limit?.context ? `${contextTokens} ctx` : ''}
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
{metadata?.limit?.output ? `${outputTokens} out` : ''}
</span>
) : null}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{indicatorIcons.map(({ id, icon: Icon, label }) => (
<span
key={id}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
aria-label={label}
role="img"
title={label}
>
<Icon className="h-3 w-3" />
</span>
))}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-4 w-4 items-center justify-center text-yellow-500 hover:text-yellow-600"
aria-label="Unfavorite"
title="Remove from favorites"
>
<RiStarFill className="h-3.5 w-3.5" />
</button>
</div>
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Recents Section */}
{recentModelsList.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="typography-meta">
<RiTimeLine className="h-3 w-3 flex-shrink-0 mr-2 text-muted-foreground" />
Recent
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
>
{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 (
<DropdownMenuItem
key={`recent-${providerID}-${modelID}`}
className="typography-meta"
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID);
}}
>
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
{metadata?.limit?.context || metadata?.limit?.output ? (
<span className="typography-meta text-muted-foreground">
{metadata?.limit?.context ? `${contextTokens} ctx` : ''}
{metadata?.limit?.context && metadata?.limit?.output ? ' • ' : ''}
{metadata?.limit?.output ? `${outputTokens} out` : ''}
</span>
) : null}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{indicatorIcons.map(({ id, icon: Icon, label }) => (
<span
key={id}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
aria-label={label}
role="img"
title={label}
>
<Icon className="h-3 w-3" />
</span>
))}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-yellow-600"
aria-label="Favorite"
title="Add to favorites"
>
<RiStarLine className="h-3.5 w-3.5" />
</button>
</div>
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Separator before providers */}
{(favoriteModelsList.length > 0 || recentModelsList.length > 0) && (
<DropdownMenuSeparator />
)}
{/* All Providers Section */}
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
@@ -1561,13 +1873,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<DropdownMenuItem
key={model.id}
className="typography-meta"
onSelect={() => {
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(provider.id as string, model.id as string);
}}
>
<div className="flex items-center gap-2">
<div className="flex flex-col">
<span className="font-medium">
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
{metadata?.limit?.context || metadata?.limit?.output ? (
@@ -1578,7 +1891,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
</span>
) : null}
</div>
<div className="ml-auto flex items-center gap-1">
<div className="flex items-center gap-1 flex-shrink-0">
{indicatorIcons.map(({ id, icon: Icon, label }) => (
<span
key={id}
@@ -1590,6 +1903,27 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<Icon className="h-3 w-3" />
</span>
))}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(provider.id as string, model.id as string);
}}
className={cn(
"model-favorite-button flex h-4 w-4 items-center justify-center hover:text-yellow-600",
isFavoriteModel(provider.id as string, model.id as string)
? "text-yellow-500"
: "text-muted-foreground"
)}
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) ? (
<RiStarFill className="h-3.5 w-3.5" />
) : (
<RiStarLine className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</DropdownMenuItem>
@@ -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<string>() };
return { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
},
[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<string>() };
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
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<string>() };
const state = next.get(turnId) ?? { isExpanded: defaultActivityExpanded, previewedPartIds: new Set<string>() };
const newPreviewed = new Set(state.previewedPartIds);
partIds.forEach((id) => {
if (id && id.trim().length > 0) {
@@ -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<VSCodeView>('sessions');
@@ -200,7 +201,11 @@ export const VSCodeLayout: React.FC = () => {
<div className="h-full w-full bg-background text-foreground flex flex-col">
{currentView === 'sessions' ? (
<div className="flex flex-col h-full">
<VSCodeHeader title="Sessions" onNewSession={handleNewSession} />
<VSCodeHeader
title="Sessions"
onNewSession={handleNewSession}
onSettings={() => setCurrentView('settings')}
/>
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
@@ -210,6 +215,17 @@ export const VSCodeLayout: React.FC = () => {
/>
</div>
</div>
) : currentView === 'settings' ? (
<div className="flex flex-col h-full">
<VSCodeHeader
title="Settings"
showBack
onBack={handleBackToSessions}
/>
<div className="flex-1 overflow-y-auto">
<VSCodeSettingsView />
</div>
</div>
) : (
<div className="flex flex-col h-full">
<VSCodeHeader
@@ -249,10 +265,11 @@ interface VSCodeHeaderProps {
showBack?: boolean;
onBack?: () => void;
onNewSession?: () => void;
onSettings?: () => void;
showContextUsage?: boolean;
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, showContextUsage }) => {
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, showContextUsage }) => {
const { getCurrentModel } = useConfigStore();
const getContextUsage = useSessionStore((state) => state.getContextUsage);
@@ -285,6 +302,15 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
<RiAddLine className="h-5 w-5" />
</button>
)}
{onSettings && (
<button
onClick={onSettings}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Settings"
>
<RiSettings3Line className="h-5 w-5" />
</button>
)}
{showContextUsage && contextUsage && contextUsage.totalTokens > 0 && (
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
@@ -297,3 +323,14 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
</div>
);
};
const VSCodeSettingsView: React.FC = () => {
return (
<div className="flex flex-col h-full">
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<SettingsPage />
</div>
</div>
);
};
@@ -6,12 +6,14 @@ interface ThemeProviderProps {
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ 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(() => {
@@ -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<string, unknown> & { id?: string; name?: string };
@@ -34,6 +36,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}) => {
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<ModelSelectorProps> = ({
});
};
const getModelDisplayName = (model: Record<string, unknown>) => {
const name = model?.name || model?.id || '';
const nameStr = String(name);
@@ -69,6 +75,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
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<ModelSelectorProps> = ({
title="Select Model"
>
<div className="space-y-1">
{/* Favorites Section for Mobile */}
{favoriteModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95 mb-2">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Favorites
</div>
<div className="border-t border-border/20">
{favoriteModelsList.map(({ model, providerID, modelID }) => {
const isSelectedModel = providerID === providerId && modelID === modelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<div
key={`fav-mobile-${providerID}-${modelID}`}
className={cn(
'flex w-full items-center justify-between px-2 py-1.5 text-left',
'typography-meta',
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
>
<button
type="button"
className="flex-1 flex flex-col min-w-0 mr-2"
onClick={() => {
handleProviderAndModelChange(providerID, modelID);
closeMobilePanel();
}}
>
<div className="flex items-center gap-2">
<ProviderLogo
providerId={providerID}
className="h-3 w-3 flex-shrink-0"
/>
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
</div>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-8 w-8 items-center justify-center text-yellow-500 hover:text-yellow-600 active:scale-95 touch-manipulation"
aria-label="Unfavorite"
>
<RiStarFill className="h-4 w-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Recents Section for Mobile */}
{recentModelsList.length > 0 && (
<div className="rounded-xl border border-border/40 bg-background/95 mb-2">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Recents
</div>
<div className="border-t border-border/20">
{recentModelsList.map(({ model, providerID, modelID }) => {
const isSelectedModel = providerID === providerId && modelID === modelId;
const metadata = getModelMetadata(providerID, modelID);
return (
<div
key={`recent-mobile-${providerID}-${modelID}`}
className={cn(
'flex w-full items-center justify-between px-2 py-1.5 text-left',
'typography-meta',
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
>
<button
type="button"
className="flex-1 flex flex-col min-w-0 mr-2"
onClick={() => {
handleProviderAndModelChange(providerID, modelID);
closeMobilePanel();
}}
>
<div className="flex items-center gap-2">
<ProviderLogo
providerId={providerID}
className="h-3 w-3 flex-shrink-0"
/>
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
</div>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-8 w-8 items-center justify-center text-muted-foreground/50 hover:text-yellow-600 active:scale-95 touch-manipulation"
aria-label="Favorite"
>
<RiStarLine className="h-4 w-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{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<ModelSelectorProps> = ({
const metadata = getModelMetadata(provider.id as string, modelItem.id as string);
return (
<button
<div
key={modelItem.id as string}
type="button"
className={cn(
'flex w-full items-center justify-between px-2 py-1.5 text-left',
'typography-meta',
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
)}
onClick={() => {
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
closeMobilePanel();
}}
>
<div className="flex flex-col">
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
<button
type="button"
className="flex-1 flex flex-col min-w-0 mr-2"
onClick={() => {
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
closeMobilePanel();
}}
>
<span className="font-medium truncate">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground">
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(provider.id as string, modelItem.id as string);
}}
className={cn(
"flex h-8 w-8 items-center justify-center active:scale-95 touch-manipulation",
isFavoriteModel(provider.id as string, modelItem.id as string)
? "text-yellow-500"
: "text-muted-foreground/50"
)}
aria-label={isFavoriteModel(provider.id as string, modelItem.id as string) ? "Unfavorite" : "Favorite"}
>
{isFavoriteModel(provider.id as string, modelItem.id as string) ? (
<RiStarFill className="h-4 w-4" />
) : (
<RiStarLine className="h-4 w-4" />
)}
</button>
{isSelectedModel && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
{isSelectedModel && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</button>
</div>
);
})}
</div>
@@ -220,6 +374,125 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-w-[300px]">
{/* Favorites Section */}
{favoriteModelsList.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="typography-meta">
<RiStarFill className="h-3 w-3 flex-shrink-0 mr-2 text-yellow-500" />
Favorites
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
className="space-y-1 p-1"
>
{favoriteModelsList.map(({ model, providerID, modelID }) => {
const metadata = getModelMetadata(providerID, modelID);
return (
<DropdownMenuItem
key={`fav-${providerID}-${modelID}`}
className="typography-meta"
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID);
}}
>
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-4 w-4 items-center justify-center text-yellow-500 hover:text-yellow-600"
aria-label="Unfavorite"
>
<RiStarFill className="h-3.5 w-3.5" />
</button>
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Recents Section */}
{recentModelsList.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="typography-meta">
<RiTimeLine className="h-3 w-3 flex-shrink-0 mr-2 text-muted-foreground" />
Recent
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="max-h-[320px] min-w-[200px]"
sideOffset={2}
collisionPadding={8}
avoidCollisions={true}
>
<ScrollableOverlay
outerClassName="max-h-[320px] min-w-[200px]"
className="space-y-1 p-1"
>
{recentModelsList.map(({ model, providerID, modelID }) => {
const metadata = getModelMetadata(providerID, modelID);
return (
<DropdownMenuItem
key={`recent-${providerID}-${modelID}`}
className="typography-meta"
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(providerID, modelID);
}}
>
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{getModelDisplayName(model)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(providerID, modelID);
}}
className="model-favorite-button flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-yellow-600"
aria-label="Favorite"
>
<RiStarLine className="h-3.5 w-3.5" />
</button>
</div>
</DropdownMenuItem>
);
})}
</ScrollableOverlay>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Separator before providers */}
{(favoriteModelsList.length > 0 || recentModelsList.length > 0) && (
<DropdownMenuSeparator />
)}
{providers.map((provider) => {
const providerModels = Array.isArray(provider.models) ? provider.models : [];
@@ -265,17 +538,40 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
<DropdownMenuItem
key={modelItem.id as string}
className="typography-meta"
onSelect={() => {
onSelect={(e) => {
e.preventDefault();
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
}}
>
<div className="flex flex-col">
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-meta text-muted-foreground">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{getModelDisplayName(modelItem)}</span>
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
<span className="typography-micro text-muted-foreground truncate">
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
</span>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFavoriteModel(provider.id as string, modelItem.id as string);
}}
className={cn(
"flex h-4 w-4 items-center justify-center hover:text-yellow-600",
isFavoriteModel(provider.id as string, modelItem.id as string)
? "text-yellow-500"
: "text-muted-foreground"
)}
aria-label={isFavoriteModel(provider.id as string, modelItem.id as string) ? "Unfavorite" : "Favorite"}
>
{isFavoriteModel(provider.id as string, modelItem.id as string) ? (
<RiStarFill className="h-3.5 w-3.5" />
) : (
<RiStarLine className="h-3.5 w-3.5" />
)}
</button>
</div>
</DropdownMenuItem>
);
@@ -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<T extends string> {
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 (
<div className="w-full space-y-8">
{}
{!isVSCodeRuntime() && (
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Theme Mode
</h3>
</div>
<div className="flex gap-1 w-fit">
{THEME_MODE_OPTIONS.map((option) => (
<ButtonSmall
key={option.value}
variant={themeMode === option.value ? 'default' : 'outline'}
className={cn(themeMode === option.value ? undefined : 'text-foreground')}
onClick={() => setThemeMode(option.value)}
>
{option.label}
</ButtonSmall>
))}
</div>
</div>
)}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Theme Mode
Font Size
</h3>
<p className="typography-meta text-muted-foreground">
{fontSize}% of default size
</p>
</div>
<div className="flex items-center gap-4">
<input
type="range"
min="50"
max="200"
step="5"
value={fontSize}
onChange={(e) => 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"
/>
<input
type="number"
min="50"
max="200"
step="5"
value={fontSize}
onChange={(e) => 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"
/>
<button
onClick={() => setFontSize(100)}
className="px-2 py-1 text-xs border border-border rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
Reset
</button>
</div>
</div>
{}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Spacing
</h3>
<p className="typography-meta text-muted-foreground">
{padding}% of default spacing
</p>
</div>
<div className="flex items-center gap-4">
<input
type="range"
min="50"
max="200"
step="5"
value={padding}
onChange={(e) => 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"
/>
<input
type="number"
min="50"
max="200"
step="5"
value={padding}
onChange={(e) => 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"
/>
<button
onClick={() => setPadding(100)}
className="px-2 py-1 text-xs border border-border rounded hover:bg-accent text-muted-foreground hover:text-foreground"
>
Reset
</button>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">
Default Tool Output
</h3>
<p className="typography-meta text-muted-foreground">
{TOOL_EXPANSION_OPTIONS.find(o => o.value === toolCallExpansion)?.description}
</p>
</div>
<div className="flex gap-1 w-fit">
{THEME_MODE_OPTIONS.map((option) => (
{TOOL_EXPANSION_OPTIONS.map((option) => (
<ButtonSmall
key={option.value}
variant={themeMode === option.value ? 'default' : 'outline'}
className={cn(themeMode === option.value ? undefined : 'text-foreground')}
onClick={() => setThemeMode(option.value)}
variant={toolCallExpansion === option.value ? 'default' : 'outline'}
className={cn(toolCallExpansion === option.value ? undefined : 'text-foreground')}
onClick={() => setToolCallExpansion(option.value)}
>
{option.label}
</ButtonSmall>
+7 -2
View File
@@ -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',
+50
View File
@@ -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<Provider, "models"> & { 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 };
};
+56 -2
View File
@@ -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);
+22
View File
@@ -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',
+140
View File
@@ -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<string, 'inline' | 'side-by-side'>;
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<UIStore>()(
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<UIStore>()(
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<string, string> = {
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<UIStore>()(
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<UIStore>()(
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,
})
+7 -1
View File
@@ -263,7 +263,13 @@ export function VSCodeApp() {
<div className="flex flex-col h-full bg-background text-foreground">
<ConnectionStatusBanner status={status} error={error} onRetry={connect} />
<div className="flex-1 min-h-0">
{currentView === 'sessions' ? <SessionsList /> : <ChatPanel />}
{currentView === 'sessions' ? (
<SessionsList />
) : currentView === 'settings' ? (
<SettingsView />
) : (
<ChatPanel />
)}
</div>
</div>
);
@@ -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<NavigationState>((set) => ({
@@ -14,4 +15,5 @@ export const useNavigation = create<NavigationState>((set) => ({
navigateTo: (view) => set({ currentView: view }),
goToChat: () => set({ currentView: 'chat' }),
goToSessions: () => set({ currentView: 'sessions' }),
goToSettings: () => set({ currentView: 'settings' }),
}));