Files
hikari-desktop/src/lib/stores/config.ts
T
hikari b745100bd5
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m28s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
feat: Claude CLI 2.1.50–2.1.53 audit (#171)
## Summary

This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review.

### New Features
- **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly
- **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active
- **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead)
- **Org UUID in account info** — exposes the org UUID from Claude auth status

### Bug Fixes
- **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164)
- **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166)
- **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165)
- **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169)

### Maintenance
- Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162)
- Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163)
- Expose org UUID from `claude auth status` (Closes #160)
- Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import)
- Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147)
- Run `cargo update` to bring Cargo.lock up to date

### Closes

Closes #160
Closes #162
Closes #163
Closes #164
Closes #165
Closes #166
Closes #167
Closes #168
Closes #169
Closes #81
Closes #82
Closes #83
Closes #84
Closes #85
Closes #86
Closes #87
Closes #90
Closes #91
Closes #93
Closes #94
Closes #95
Closes #96
Closes #97
Closes #98
Closes #99
Closes #101
Closes #141
Closes #142
Closes #143
Closes #145
Closes #146
Closes #147

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #171
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-25 22:55:47 -08:00

337 lines
11 KiB
TypeScript

import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export type Theme = "dark" | "light" | "high-contrast" | "custom";
export type BudgetAction = "warn" | "block";
export interface CustomThemeColors {
bg_primary: string | null;
bg_secondary: string | null;
bg_terminal: string | null;
accent_primary: string | null;
accent_secondary: string | null;
text_primary: string | null;
text_secondary: string | null;
border_color: string | null;
}
export interface HikariConfig {
model: string | null;
api_key: string | null;
custom_instructions: string | null;
mcp_servers_json: string | null;
auto_granted_tools: string[];
theme: Theme;
greeting_enabled: boolean;
greeting_custom_prompt: string | null;
notifications_enabled: boolean;
notification_volume: number;
always_on_top: boolean;
update_checks_enabled: boolean;
character_panel_width: number | null;
font_size: number;
streamer_mode: boolean;
streamer_hide_paths: boolean;
compact_mode: boolean;
profile_name: string | null;
profile_avatar_path: string | null;
profile_bio: string | null;
custom_theme_colors: CustomThemeColors;
// Budget settings
budget_enabled: boolean;
session_token_budget: number | null;
session_cost_budget: number | null;
budget_action: BudgetAction;
budget_warning_threshold: number;
// Discord RPC settings
discord_rpc_enabled: boolean;
// Thinking blocks settings
show_thinking_blocks: boolean;
// Worktree isolation
use_worktree: boolean;
// Disable 1M context window
disable_1m_context: boolean;
// Workspaces the user has explicitly trusted
trusted_workspaces: string[];
// Background image settings
background_image_path: string | null;
background_image_opacity: number;
}
const defaultConfig: HikariConfig = {
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
theme: "dark",
greeting_enabled: true,
greeting_custom_prompt: null,
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
};
function createConfigStore() {
const config = writable<HikariConfig>(defaultConfig);
const isLoading = writable<boolean>(true);
const isSidebarOpen = writable<boolean>(false);
const saveError = writable<string | null>(null);
// Internal function to get current config synchronously
function getCurrentConfig(): HikariConfig {
let currentConfig: HikariConfig = defaultConfig;
const unsubscribe = config.subscribe((c) => (currentConfig = c));
unsubscribe();
return currentConfig;
}
async function loadConfig() {
isLoading.set(true);
try {
const savedConfig = await invoke<HikariConfig>("get_config");
config.set(savedConfig);
} catch (error) {
console.error("Failed to load config:", error);
config.set(defaultConfig);
} finally {
isLoading.set(false);
}
}
async function saveConfig(newConfig: HikariConfig) {
saveError.set(null);
try {
await invoke("save_config", { config: newConfig });
config.set(newConfig);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
saveError.set(errorMessage);
console.error("Failed to save config:", error);
throw error;
}
}
async function updateConfig(updates: Partial<HikariConfig>) {
const currentConfig = getCurrentConfig();
const newConfig = { ...currentConfig, ...updates };
await saveConfig(newConfig);
}
return {
config: { subscribe: config.subscribe },
isLoading: { subscribe: isLoading.subscribe },
isSidebarOpen: { subscribe: isSidebarOpen.subscribe },
saveError: { subscribe: saveError.subscribe },
loadConfig,
saveConfig,
updateConfig,
openSidebar: () => isSidebarOpen.set(true),
closeSidebar: () => isSidebarOpen.set(false),
toggleSidebar: () => isSidebarOpen.update((open) => !open),
setTheme: async (theme: Theme, customColors?: CustomThemeColors) => {
const updates: Partial<HikariConfig> = { theme };
if (customColors) {
updates.custom_theme_colors = customColors;
}
await updateConfig(updates);
const currentConfig = getCurrentConfig();
applyTheme(theme, currentConfig.custom_theme_colors);
},
setCustomThemeColors: async (colors: CustomThemeColors) => {
await updateConfig({ custom_theme_colors: colors });
const currentConfig = getCurrentConfig();
if (currentConfig.theme === "custom") {
applyCustomThemeColors(colors);
}
},
setFontSize: async (size: number) => {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
await updateConfig({ font_size: clampedSize });
applyFontSize(clampedSize);
},
increaseFontSize: async () => {
const currentConfig = getCurrentConfig();
const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
decreaseFontSize: async () => {
const currentConfig = getCurrentConfig();
const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2);
await updateConfig({ font_size: newSize });
applyFontSize(newSize);
},
resetFontSize: async () => {
await updateConfig({ font_size: DEFAULT_FONT_SIZE });
applyFontSize(DEFAULT_FONT_SIZE);
},
addAutoGrantedTool: async (tool: string) => {
const currentConfig = getCurrentConfig();
if (!currentConfig.auto_granted_tools.includes(tool)) {
const newTools = [...currentConfig.auto_granted_tools, tool];
await updateConfig({ auto_granted_tools: newTools });
}
},
removeAutoGrantedTool: async (tool: string) => {
const currentConfig = getCurrentConfig();
const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool);
await updateConfig({ auto_granted_tools: newTools });
},
getConfig: (): HikariConfig => {
return getCurrentConfig();
},
toggleStreamerMode: async () => {
const currentConfig = getCurrentConfig();
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
},
toggleCompactMode: async () => {
const currentConfig = getCurrentConfig();
await updateConfig({ compact_mode: !currentConfig.compact_mode });
},
setCompactMode: async (enabled: boolean) => {
await updateConfig({ compact_mode: enabled });
},
};
}
export function applyTheme(theme: Theme, customColors?: CustomThemeColors) {
if (typeof document !== "undefined") {
// For custom theme, we use dark as the base and override with custom colors
document.documentElement.setAttribute("data-theme", theme === "custom" ? "dark" : theme);
// Clear any previously applied custom colors
clearCustomThemeColors();
// Apply custom colors if theme is custom
if (theme === "custom" && customColors) {
applyCustomThemeColors(customColors);
}
}
}
export function applyCustomThemeColors(colors: CustomThemeColors) {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (colors.bg_primary) root.style.setProperty("--bg-primary", colors.bg_primary);
if (colors.bg_secondary) root.style.setProperty("--bg-secondary", colors.bg_secondary);
if (colors.bg_terminal) root.style.setProperty("--bg-terminal", colors.bg_terminal);
if (colors.accent_primary) root.style.setProperty("--accent-primary", colors.accent_primary);
if (colors.accent_secondary)
root.style.setProperty("--accent-secondary", colors.accent_secondary);
if (colors.text_primary) root.style.setProperty("--text-primary", colors.text_primary);
if (colors.text_secondary) root.style.setProperty("--text-secondary", colors.text_secondary);
if (colors.border_color) root.style.setProperty("--border-color", colors.border_color);
}
export function clearCustomThemeColors() {
if (typeof document === "undefined") return;
const root = document.documentElement;
const customProperties = [
"--bg-primary",
"--bg-secondary",
"--bg-terminal",
"--accent-primary",
"--accent-secondary",
"--text-primary",
"--text-secondary",
"--border-color",
];
customProperties.forEach((prop) => root.style.removeProperty(prop));
}
const MIN_FONT_SIZE = 10;
const MAX_FONT_SIZE = 24;
const DEFAULT_FONT_SIZE = 14;
export function applyFontSize(size: number) {
if (typeof document !== "undefined") {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`);
}
}
export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore();
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
export const isStreamerMode = derived(configStore.config, ($config) => $config.streamer_mode);
export const isCompactMode = derived(configStore.config, ($config) => $config.compact_mode);
export const shouldHidePaths = derived(
configStore.config,
($config) => $config.streamer_mode && $config.streamer_hide_paths
);
export const showThinkingBlocks = derived(
configStore.config,
($config) => $config.show_thinking_blocks
);
/**
* Masks file paths in text when streamer mode with hide paths is enabled.
* Replaces username portion of paths with asterisks.
*/
export function maskPaths(text: string, hidePaths: boolean): string {
if (!hidePaths) return text;
// Match Unix paths like /home/username/... or /Users/username/...
// and Windows paths like C:\Users\username\...
return text
.replace(/\/home\/([^/\s]+)/g, "/home/****")
.replace(/\/Users\/([^/\s]+)/g, "/Users/****")
.replace(/C:\\Users\\([^\\\s]+)/gi, "C:\\Users\\****")
.replace(/~\//g, "****/");
}