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
+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,
})