generated from nhcarrigan/template
e6e9f7ae59
## Summary A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation. ### Features - **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion - **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control - **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel - **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost - **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume - **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically - **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure - **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine - **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app - **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature ### Performance & Fixes - **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions - **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected ### UX - Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation - Navigation icon cluster replaced with a single clean dropdown menu ## Closes Closes #179 Closes #180 Closes #181 Closes #188 Closes #189 Closes #190 Closes #191 Closes #192 Closes #193 Closes #194 Closes #195 Closes #196 --- ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #197 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
import { writable, derived } from "svelte/store";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { readFile } from "@tauri-apps/plugin-fs";
|
|
|
|
export type Theme =
|
|
| "dark"
|
|
| "light"
|
|
| "high-contrast"
|
|
| "custom"
|
|
| "dracula"
|
|
| "catppuccin"
|
|
| "nord"
|
|
| "solarized"
|
|
| "solarized-light"
|
|
| "catppuccin-latte"
|
|
| "gruvbox-light"
|
|
| "rose-pine-dawn";
|
|
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;
|
|
// Max output tokens for Claude Code responses
|
|
max_output_tokens: number | null;
|
|
// Workspaces the user has explicitly trusted
|
|
trusted_workspaces: string[];
|
|
// Background image settings
|
|
background_image_path: string | null;
|
|
background_image_opacity: number;
|
|
// Custom terminal font settings
|
|
custom_font_path: string | null;
|
|
custom_font_family: string | null;
|
|
// Custom UI font settings
|
|
custom_ui_font_path: string | null;
|
|
custom_ui_font_family: string | null;
|
|
// Task Loop auto-commit settings
|
|
task_loop_auto_commit: boolean;
|
|
task_loop_commit_prefix: string;
|
|
task_loop_include_summary: boolean;
|
|
}
|
|
|
|
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,
|
|
max_output_tokens: null,
|
|
trusted_workspaces: [],
|
|
background_image_path: null,
|
|
background_image_opacity: 0.3,
|
|
custom_font_path: null,
|
|
custom_font_family: null,
|
|
custom_ui_font_path: null,
|
|
custom_ui_font_family: null,
|
|
task_loop_auto_commit: false,
|
|
task_loop_commit_prefix: "feat",
|
|
task_loop_include_summary: false,
|
|
};
|
|
|
|
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 });
|
|
},
|
|
|
|
setCustomFont: async (path: string | null, family: string | null) => {
|
|
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
|
await applyCustomFont(path, family);
|
|
},
|
|
|
|
setCustomUiFont: async (path: string | null, family: string | null) => {
|
|
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
|
|
await applyCustomUiFont(path, family);
|
|
},
|
|
};
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
|
|
|
|
const FONT_MIME_MAP: Record<string, string> = {
|
|
woff: "font/woff",
|
|
woff2: "font/woff2",
|
|
ttf: "font/ttf",
|
|
otf: "font/otf",
|
|
eot: "application/vnd.ms-fontobject",
|
|
};
|
|
|
|
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
|
|
const style = document.createElement("style");
|
|
style.id = styleId;
|
|
|
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
|
|
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
|
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
|
|
} else {
|
|
style.textContent = `@import url('${path}');`;
|
|
}
|
|
} else {
|
|
const data = await readFile(path);
|
|
const chunks: string[] = [];
|
|
const chunkSize = 8192;
|
|
for (let i = 0; i < data.length; i += chunkSize) {
|
|
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
|
|
}
|
|
const ext = path.split(".").pop()?.toLowerCase() ?? "ttf";
|
|
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
|
|
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
|
|
}
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
|
if (typeof document === "undefined") return;
|
|
|
|
const styleId = "hikari-custom-font";
|
|
const cssVar = "--terminal-font-family";
|
|
const fallbackFamily = "HikariCustomFont";
|
|
|
|
document.getElementById(styleId)?.remove();
|
|
|
|
const trimmedPath = path?.trim() ?? "";
|
|
const trimmedFamily = family?.trim() ?? "";
|
|
|
|
if (!trimmedPath && !trimmedFamily) {
|
|
document.documentElement.style.removeProperty(cssVar);
|
|
return;
|
|
}
|
|
|
|
if (trimmedPath) {
|
|
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
|
}
|
|
|
|
if (trimmedFamily) {
|
|
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
|
|
}
|
|
}
|
|
|
|
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
|
|
if (typeof document === "undefined") return;
|
|
|
|
const styleId = "hikari-custom-ui-font";
|
|
const cssVar = "--ui-font-family";
|
|
const fallbackFamily = "HikariCustomUiFont";
|
|
|
|
document.getElementById(styleId)?.remove();
|
|
|
|
const trimmedPath = path?.trim() ?? "";
|
|
const trimmedFamily = family?.trim() ?? "";
|
|
|
|
if (!trimmedPath && !trimmedFamily) {
|
|
document.documentElement.style.removeProperty(cssVar);
|
|
document.body?.style.removeProperty("font-family");
|
|
return;
|
|
}
|
|
|
|
const effectiveFamily = trimmedFamily || fallbackFamily;
|
|
|
|
if (trimmedPath) {
|
|
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
|
|
}
|
|
|
|
const fontValue = `'${effectiveFamily}', sans-serif`;
|
|
document.documentElement.style.setProperty(cssVar, fontValue);
|
|
document.body?.style.setProperty("font-family", fontValue);
|
|
}
|
|
|
|
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, "****/");
|
|
}
|