diff --git a/app/api/themes/[name]/route.ts b/app/api/themes/[name]/route.ts new file mode 100644 index 000000000..5db73f2fe --- /dev/null +++ b/app/api/themes/[name]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveTheme, type ThemeVariant } from "@/lib/theme"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ name: string }> }, +) { + try { + const { name } = await params; + const { searchParams } = new URL(request.url); + const mode = (searchParams.get("mode") || "dark") as ThemeVariant; + + const resolved = resolveTheme( + decodeURIComponent(name), + mode === "light" ? "light" : "dark", + ); + + if (!resolved) { + return NextResponse.json( + { error: `Theme "${name}" variant "${mode}" not found` }, + { status: 404 }, + ); + } + + return NextResponse.json(resolved); + } catch (error) { + console.error("Failed to resolve theme:", error); + return NextResponse.json( + { error: "Failed to resolve theme" }, + { status: 500 }, + ); + } +} diff --git a/app/api/themes/route.ts b/app/api/themes/route.ts new file mode 100644 index 000000000..8e2829d70 --- /dev/null +++ b/app/api/themes/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { listThemeSets } from "@/lib/theme"; + +export async function GET() { + try { + const themeSets = listThemeSets(); + + return NextResponse.json({ themeSets }); + } catch (error) { + console.error("Failed to list themes:", error); + return NextResponse.json( + { error: "Failed to list themes" }, + { status: 500 }, + ); + } +} diff --git a/app/globals.css b/app/globals.css index 1c0e917bd..df4f55e05 100644 --- a/app/globals.css +++ b/app/globals.css @@ -23,12 +23,18 @@ --bg-panel: #f5f5f5; --bg-hover: #eeeeee; --bg-selected: #e8e8e8; + --bg-sidebar: #eeeeee; --border: #e0e0e0; --text: #1a1a1a; --text-muted: #6b7280; --text-dim: #9ca3af; --accent: #2563eb; --accent-hover: #1d4ed8; + /* 语法高亮语义色(prism-theme 引用;主题 JSON 映射会覆盖) */ + --accent-blue: #2563eb; + --accent-red: #dc2626; + --accent-green: #16a34a; + --accent-orange: #d97706; --user-bg: #eff6ff; --assistant-bg: #ffffff; --tool-bg: #f9fafb; @@ -40,12 +46,18 @@ html.dark { --bg-panel: #242424; --bg-hover: #2e2e2e; --bg-selected: #383838; + --bg-sidebar: #2e2e2e; --border: #3a3a3a; --text: #e8e8e8; --text-muted: #9ca3af; --text-dim: #6b7280; --accent: #60a5fa; --accent-hover: #93c5fd; + /* 语法高亮语义色(prism-theme 引用;主题 JSON 映射会覆盖) */ + --accent-blue: #60a5fa; + --accent-red: #f87171; + --accent-green: #4ade80; + --accent-orange: #fb923c; --user-bg: #1e293b; --assistant-bg: #1a1a1a; --tool-bg: #1f2937; diff --git a/app/layout.tsx b/app/layout.tsx index 054989f56..60ed55931 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -63,7 +63,7 @@ export default function RootLayout({ diff --git a/components/AppShell.tsx b/components/AppShell.tsx index d4d827fa7..d76523351 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -36,6 +36,7 @@ import type { SessionInfo, SessionTreeNode } from "@/lib/types"; import type { ProjectTrustStatus } from "@/lib/api-types"; import type { ChatInputHandle } from "./ChatInput"; import type { SessionStatsInfo } from "@/lib/pi-types"; +import type { ThemeSetInfo } from "@/lib/theme"; type SessionCopyField = "file" | "id"; type AutoNameStatus = @@ -46,12 +47,13 @@ type AutoNameStatus = const TOP_BAR_ICON_BUTTON_SIZE = 36; const LANGUAGE_MENU_WIDTH = 176; +const THEME_MENU_WIDTH = 200; export function AppShell() { const router = useRouter(); const searchParams = useSearchParams(); const [initialNavigation] = useState(() => getInitialNavigation(searchParams)); - const { isDark, toggleTheme } = useTheme(); + const { isDark, toggleTheme, themeName, setTheme } = useTheme(); const { locale, setLocale, t: translate, supportedLocales } = useI18n(); const isMobile = useIsMobile(); useViewportHeight(); @@ -145,6 +147,24 @@ export function AppShell() { const chatInputRef = useRef(null); const topBarRef = useRef(null); const languageBtnRef = useRef(null); + const themeBtnRef = useRef(null); + + // 主题集列表(来自 ~/.pi/agent/themes/ 的 pi theme JSON,经 /api/themes 发现) + const [themeSets, setThemeSets] = useState([]); + useEffect(() => { + let cancelled = false; + fetch("/api/themes") + .then((resp) => (resp.ok ? resp.json() : null)) + .then((data: { themeSets?: ThemeSetInfo[] } | null) => { + if (!cancelled && data?.themeSets) setThemeSets(data.themeSets); + }) + .catch(() => { + // 主题列表加载失败时面板只显示默认主题,不阻塞主流程 + }); + return () => { + cancelled = true; + }; + }, []); // Branch navigator state — populated by ChatWindow via onBranchDataChange const [branchTree, setBranchTree] = useState([]); @@ -201,10 +221,10 @@ export function AppShell() { }, []); // Single active panel — only one dropdown open at a time - const [activeTopPanel, setActiveTopPanel] = useState<"branches" | "system" | "session" | "language" | null>(null); + const [activeTopPanel, setActiveTopPanel] = useState<"branches" | "system" | "session" | "language" | "theme" | null>(null); const [topPanelPos, setTopPanelPos] = useState<{ top: number; left: number; width: number } | null>(null); - const toggleTopPanel = useCallback((panel: "branches" | "system" | "session" | "language") => { + const toggleTopPanel = useCallback((panel: "branches" | "system" | "session" | "language" | "theme") => { if (isMobile) setSidebarOpen(false); setActiveTopPanel((cur) => cur === panel ? null : panel); }, [isMobile]); @@ -223,15 +243,22 @@ export function AppShell() { if (!activeTopPanel || !topBarRef.current) return; const update = () => { const topBarRect = topBarRef.current!.getBoundingClientRect(); - if (activeTopPanel === "language" && !isMobile && languageBtnRef.current) { - const buttonRect = languageBtnRef.current.getBoundingClientRect(); - const width = Math.min(LANGUAGE_MENU_WIDTH, topBarRect.width); - const left = Math.min( - buttonRect.left - 1, - Math.max(topBarRect.left, topBarRect.right - width), - ); - setTopPanelPos({ top: topBarRect.bottom, left, width }); - return; + // 语言 / 主题面板锚定在对应按钮下方,其余面板铺满顶栏宽 + if ((activeTopPanel === "language" || activeTopPanel === "theme") && !isMobile) { + const anchorBtn = activeTopPanel === "language" ? languageBtnRef.current : themeBtnRef.current; + if (anchorBtn) { + const buttonRect = anchorBtn.getBoundingClientRect(); + const width = Math.min( + activeTopPanel === "language" ? LANGUAGE_MENU_WIDTH : THEME_MENU_WIDTH, + topBarRect.width, + ); + const left = Math.min( + buttonRect.left - 1, + Math.max(topBarRect.left, topBarRect.right - width), + ); + setTopPanelPos({ top: topBarRect.bottom, left, width }); + return; + } } setTopPanelPos({ top: topBarRect.bottom, left: topBarRect.left, width: topBarRect.width }); }; @@ -239,6 +266,7 @@ export function AppShell() { const ro = new ResizeObserver(update); ro.observe(topBarRef.current); if (languageBtnRef.current) ro.observe(languageBtnRef.current); + if (themeBtnRef.current) ro.observe(themeBtnRef.current); return () => ro.disconnect(); }, [activeTopPanel, isMobile]); @@ -803,7 +831,7 @@ export function AppShell() { className={`sidebar-container${sidebarOpen ? " sidebar-open" : " sidebar-closed"}${mobileSidebarReady ? "" : " sidebar-mobile-pending"}${sidebarResizer.isResizing ? " sidebar-resizing" : ""}`} style={{ "--sidebar-width": `${sidebarResizer.width}px`, - background: "var(--bg-panel)", + background: "var(--bg-sidebar)", borderRight: "1px solid var(--border)", display: "flex", flexDirection: "column", @@ -883,6 +911,32 @@ export function AppShell() { )} + toggleTopPanel("theme")} + title={translate("theme.select")} + aria-label={translate("theme.select")} + aria-haspopup="menu" + aria-expanded={activeTopPanel === "theme"} + aria-pressed={activeTopPanel === "theme"} + style={{ + display: "flex", alignItems: "center", justifyContent: "center", + width: TOP_BAR_ICON_BUTTON_SIZE, height: TOP_BAR_ICON_BUTTON_SIZE, padding: 0, + background: activeTopPanel === "theme" ? "var(--bg-selected)" : "none", + border: "none", borderRight: "1px solid var(--border)", + color: activeTopPanel === "theme" ? "var(--text)" : "var(--text-muted)", + cursor: "pointer", flexShrink: 0, transition: "color 0.12s", + }} + onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }} + onMouseLeave={(e) => { e.currentTarget.style.color = activeTopPanel === "theme" ? "var(--text)" : "var(--text-muted)"; }} + > + {/* 调色板图标:主题集选择入口 */} + + + + + )} + {activeTopPanel === "theme" && ( + + { + setTheme(""); + setActiveTopPanel(null); + }} + role="menuitemradio" + aria-checked={themeName === ""} + style={{ + display: "flex", alignItems: "center", width: "100%", height: 34, padding: "0 10px", + border: "none", borderRadius: 4, + background: themeName === "" ? "var(--bg-selected)" : "transparent", + color: "var(--text)", cursor: "pointer", textAlign: "left", fontSize: 12, + transition: "background 0.1s", + }} + onMouseEnter={(e) => { + if (themeName !== "") e.currentTarget.style.background = "var(--bg-hover)"; + }} + onMouseLeave={(e) => { + if (themeName !== "") e.currentTarget.style.background = "transparent"; + }} + > + {translate("theme.default")} + {themeName === "" && ✓} + + {themeSets.length === 0 ? ( + + {translate("theme.loading")} + + ) : ( + themeSets.map((ts) => ( + { + setTheme(ts.name); + setActiveTopPanel(null); + }} + role="menuitemradio" + aria-checked={themeName === ts.name} + style={{ + display: "flex", alignItems: "center", width: "100%", height: 34, padding: "0 10px", + border: "none", borderRadius: 4, + background: themeName === ts.name ? "var(--bg-selected)" : "transparent", + color: "var(--text)", cursor: "pointer", textAlign: "left", fontSize: 12, + transition: "background 0.1s", + }} + onMouseEnter={(e) => { + if (themeName !== ts.name) e.currentTarget.style.background = "var(--bg-hover)"; + }} + onMouseLeave={(e) => { + if (themeName !== ts.name) e.currentTarget.style.background = "transparent"; + }} + > + {ts.displayName} + {themeName === ts.name && ✓} + + )) + )} + + )} {activeTopPanel === "system" && ( - + )} {!hovered && !node.isDir && gitStatus && ( diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index 9635b4c6d..74d3d0300 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -6,10 +6,8 @@ import { createElement as renderSyntaxNode, type SyntaxHighlighterProps, } from "react-syntax-highlighter"; -import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { prismTheme } from "@/lib/prism-theme"; import ReactMarkdown from "react-markdown"; -import { useTheme } from "@/hooks/useTheme"; import { DOCX_PREVIEW_MAX_BYTES, getFileExt, @@ -797,7 +795,6 @@ export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMenti } function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey, initialDisplayMode }: Props) { - const { isDark } = useTheme(); const { t } = useI18n(); const [data, setData] = useState(null); const [gitDiff, setGitDiff] = useState(null); @@ -1206,7 +1203,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL ); })} diff --git a/hooks/useTheme.ts b/hooks/useTheme.ts index 9000f4699..e063249a6 100644 --- a/hooks/useTheme.ts +++ b/hooks/useTheme.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useSyncExternalStore } from "react"; type Theme = "light" | "dark"; @@ -22,11 +22,108 @@ function getServerSnapshot(): Theme { return "light"; } +// ─── 主题名(pi theme JSON 集,如 gruvbox / solarized)────────────────────── +// +// localStorage "pi-theme" 同时承载历史明暗值,兼容映射: +// "light" / "dark" → 默认主题(空字符串,globals.css 硬编码变量) +// 其他值(如 "gruvbox")→ pi theme JSON 主题集名 +// 主题名持久化后,明暗偏好不单独存储:切回默认主题前,明暗随系统偏好(bootstrap 处理)。 + +function readStoredTheme(): string { + try { + const v = localStorage.getItem("pi-theme"); + if (v && v !== "light" && v !== "dark") return v; + } catch { + // 忽略存储错误(隐私模式、配额等) + } + return ""; +} + +function getThemeSnapshot(): string { + if (typeof document === "undefined") return ""; + const dt = document.documentElement.dataset.theme; + if (dt !== undefined) return dt; + return readStoredTheme(); +} + +// ─── CSS 变量应用 ──────────────────────────────────────────────────────────── +// +// 与 lib/theme.ts mapToCssVars 输出严格一一对应(20 个),均为当前 UI +// 实际消费的变量。--composer-focus-bg 为 Kabochar 独有,由 mapToCssVars +// 从主题面板色派生(聚焦态与主题协调)。 + +const THEME_CSS_VARS = [ + "--bg", "--bg-panel", "--bg-hover", "--bg-selected", "--bg-sidebar", "--bg-subtle", + "--border", + "--text", "--text-muted", "--text-dim", + "--accent", "--accent-hover", "--accent-blue", "--accent-red", "--accent-green", "--accent-orange", + "--user-bg", "--assistant-bg", "--tool-bg", + "--composer-focus-bg", +]; + +function applyCssVars(vars: Record) { + const el = document.documentElement; + for (const k of THEME_CSS_VARS) { + if (vars[k]) el.style.setProperty(k, vars[k]); + else el.style.removeProperty(k); + } +} + +function clearCssVars() { + const el = document.documentElement; + for (const k of THEME_CSS_VARS) el.style.removeProperty(k); +} + +/** 解析缓存按 `name::mode` 键控,避免重复 fetch 同一变体。 */ +const themeCache = new Map | null>(); + +async function fetchThemeVars(name: string, mode: Theme): Promise | null> { + const cacheKey = `${name}::${mode}`; + if (themeCache.has(cacheKey)) return themeCache.get(cacheKey)!; + try { + const resp = await fetch(`/api/themes/${encodeURIComponent(name)}?mode=${mode}`); + if (!resp.ok) return null; + const data: { cssVars: Record } = await resp.json(); + themeCache.set(cacheKey, data.cssVars); + return data.cssVars; + } catch { + return null; + } +} + +/** 应用主题集指定变体的 CSS 变量;name 为空时清空回落到 globals.css 默认。 */ +async function applyTheme(name: string, mode: Theme) { + const el = document.documentElement; + if (name) { + el.dataset.theme = name; + const vars = await fetchThemeVars(name, mode); + if (vars) { + applyCssVars(vars); + } else { + console.warn(`Theme "${name}" variant "${mode}" not found, using defaults`); + clearCssVars(); + } + } else { + delete el.dataset.theme; + clearCssVars(); + } +} + type ToggleOrigin = { x: number; y: number }; export function useTheme() { const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const setTheme = useCallback(async (name: string) => { + await applyTheme(name, getSnapshot()); + try { + localStorage.setItem("pi-theme", name); + } catch { + // 忽略存储错误 + } + listeners.forEach((cb) => cb()); + }, []); + const toggleTheme = useCallback((origin?: ToggleOrigin) => { const next: Theme = getSnapshot() === "dark" ? "light" : "dark"; @@ -36,10 +133,10 @@ export function useTheme() { } else { document.documentElement.classList.remove("dark"); } - try { - localStorage.setItem("pi-theme", next); - } catch { - // ignore storage errors (private mode, quota, etc.) + // 明暗切换后重新解析当前主题的对应变体(gruvbox → gruvbox-light 等) + const tn = getThemeSnapshot(); + if (tn) { + applyTheme(tn, next); } listeners.forEach((cb) => cb()); }; @@ -81,5 +178,22 @@ export function useTheme() { }); }, []); - return { theme, toggleTheme, isDark: theme === "dark" }; + // 挂载时应用持久化主题:bootstrap 脚本已同步设置 data-theme 与 dark class, + // 此处补齐 CSS 变量(本地 API fetch,毫秒级)。 + useEffect(() => { + const tn = getThemeSnapshot(); + if (tn) { + applyTheme(tn, getSnapshot()); + } + }, []); + + return { + theme, + /** 当前主题集名("" = 默认主题)。 */ + themeName: getThemeSnapshot(), + /** 切换主题集(如 "gruvbox"),传 "" 恢复默认。 */ + setTheme, + toggleTheme, + isDark: theme === "dark", + }; } diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..4f00fb1e1 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -14,6 +14,9 @@ export const enLocale: LocalePlugin = { "sidebar.show": "Show sidebar", "theme.light": "Switch to light mode", "theme.dark": "Switch to dark mode", + "theme.select": "Theme", + "theme.default": "Default", + "theme.loading": "Loading themes...", "history.full": "Full history", "history.unsaved": "Full history is available after the session is saved", "history.label": "Full history", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..72fd8b8d8 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -14,6 +14,9 @@ export const zhCNLocale: LocalePlugin = { "sidebar.show": "显示侧边栏", "theme.light": "切换到浅色模式", "theme.dark": "切换到深色模式", + "theme.select": "主题", + "theme.default": "默认", + "theme.loading": "正在加载主题...", "history.full": "完整历史", "history.unsaved": "会话保存后才能查看完整历史", "history.label": "完整历史", diff --git a/lib/prism-theme.ts b/lib/prism-theme.ts new file mode 100644 index 000000000..fcdbb15ca --- /dev/null +++ b/lib/prism-theme.ts @@ -0,0 +1,50 @@ +import type { CSSProperties } from "react"; + +/** + * Prism emits token colors as inline styles. These must stay tied to the same + * CSS variables as the surrounding surface: selecting a separate static dark + * or light Prism theme makes code text lag one React render behind a View + * Transition's CSS-variable update. + */ +export const prismTheme: Record = { + 'pre[class*="language-"]': { + color: "var(--text)", + background: "transparent", + }, + 'code[class*="language-"]': { + color: "var(--text)", + background: "transparent", + }, + comment: { color: "var(--text-dim)", fontStyle: "italic" }, + prolog: { color: "var(--text-dim)", fontStyle: "italic" }, + doctype: { color: "var(--text-dim)", fontStyle: "italic" }, + cdata: { color: "var(--text-dim)" }, + punctuation: { color: "var(--text-muted)" }, + operator: { color: "var(--text-muted)" }, + string: { color: "var(--accent-orange)" }, + char: { color: "var(--accent-orange)" }, + builtin: { color: "var(--accent-orange)" }, + 'attr-value': { color: "var(--accent-orange)" }, + keyword: { color: "var(--accent)" }, + atrule: { color: "var(--accent)" }, + property: { color: "var(--accent-blue)" }, + 'attr-name': { color: "var(--accent-blue)" }, + variable: { color: "var(--accent-blue)" }, + parameter: { color: "var(--accent-blue)" }, + constant: { color: "var(--accent-blue)" }, + number: { color: "var(--accent-blue)" }, + boolean: { color: "var(--accent-blue)" }, + symbol: { color: "var(--accent-blue)" }, + function: { color: "var(--accent-green)" }, + 'class-name': { color: "var(--accent-green)" }, + 'maybe-class-name': { color: "var(--accent-green)" }, + tag: { color: "var(--accent-red)" }, + selector: { color: "var(--accent-red)" }, + regex: { color: "var(--accent-red)" }, + entity: { color: "var(--accent-red)" }, + deleted: { color: "var(--accent-red)" }, + inserted: { color: "var(--accent-green)" }, + important: { color: "var(--accent-orange)", fontWeight: "bold" }, + bold: { fontWeight: "bold" }, + italic: { fontStyle: "italic" }, +}; diff --git a/lib/theme.test.mjs b/lib/theme.test.mjs new file mode 100644 index 000000000..e24336181 --- /dev/null +++ b/lib/theme.test.mjs @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// ─── 隔离 ─────────────────────────────────────────────────────────────────── +// +// lib/theme.ts 的全局主题目录是 join(os.homedir(), ".pi", "agent", "themes")。 +// 将 USERPROFILE 指向临时目录并动态 import,避免测试耦合真实 ~/.pi/agent/themes/。 +// os.homedir() 每次调用读取环境变量(无模块级缓存),动态 import 后即生效。 + +const baseDir = mkdtempSync(join(tmpdir(), "piweb-theme-test-")); +const globalThemesDir = join(baseDir, ".pi", "agent", "themes"); +mkdirSync(globalThemesDir, { recursive: true }); + +process.env.USERPROFILE = baseDir; + +const { listThemeSets, resolveTheme } = await import("./theme.ts"); + +// ─── Fixture ──────────────────────────────────────────────────────────────── +// +// pi CLI theme JSON(与官方文件名约定一致:base-dark.json / base-light.json)。 + +const GRUVBOX_DARK = { + name: "gruvbox-dark", + vars: { bg0: "#282828", bg1: "#3c3836", bg2: "#504945", bg3: "#665c54", bg4: "#7c6f64", fg0: "#fbf1c7", fg3: "#bdae93", fg4: "#a89984", orange: "#d65d0e" }, + colors: { accent: "orange", border: "bg4", text: "", muted: "fg4", dim: "fg4", selectedBg: "bg1" }, +}; + +const GRUVBOX_LIGHT = { + name: "gruvbox-light", + vars: { bg0: "#fbf1c7", bg1: "#ebdbb2", bg2: "#d5c4a1", bg3: "#bdae93", bg4: "#a89984", fg0: "#282828", fg3: "#665c54", fg4: "#7c6f64", orange: "#d65d0e" }, + colors: { accent: "orange", border: "bg4", text: "", muted: "fg4", dim: "fg4", selectedBg: "bg1" }, +}; + +const SOLARIZED_DARK = { + name: "solarized-dark", + vars: { bg0: "#002b36", bg1: "#073642", bg2: "#094250", bg3: "#586e75", bg4: "#657b83", fg0: "#839496", fg3: "#586e75", fg4: "#657b83", blue: "#268bd2" }, + colors: { accent: "blue", border: "bg3", text: "", muted: "fg4", dim: "fg4", selectedBg: "bg1" }, +}; + +// 单文件主题(无 -dark/-light 后缀),验证文件名约定外的回退路径 +const MONOKAI = { + name: "monokai", + vars: { bg0: "#272822", bg1: "#2e2e2e", bg3: "#3e3d32", fg0: "#f8f8f2", fg3: "#75715e", fg4: "#555555", green: "#a6e22e" }, + colors: { accent: "green", border: "bg3", text: "", muted: "fg4", dim: "fg4", selectedBg: "bg1" }, +}; + +const write = (name, body) => writeFileSync(join(globalThemesDir, `${name}.json`), JSON.stringify(body)); +write("gruvbox-dark", GRUVBOX_DARK); +write("gruvbox-light", GRUVBOX_LIGHT); +write("solarized-dark", SOLARIZED_DARK); +write("monokai", MONOKAI); + +test.after(() => { + rmSync(baseDir, { recursive: true, force: true }); +}); + +// ─── 对比度工具(WCAG 相对亮度) ──────────────────────────────────────────── + +function relativeLuminance(hex) { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/.exec(hex); + if (!match) return 0.5; + const [r, g, b] = [1, 2, 3].map((index) => { + const channel = parseInt(match[index], 16) / 255; + return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrastRatio(foreground, background) { + const a = relativeLuminance(foreground); + const b = relativeLuminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +// ─── listThemeSets ────────────────────────────────────────────────────────── + +test("listThemeSets 按 base 名聚合 dark/light 变体为 set", () => { + const sets = listThemeSets(); + const byName = new Map(sets.map((s) => [s.name, s])); + + assert.ok(byName.has("gruvbox"), "gruvbox set 应存在"); + assert.equal(byName.get("gruvbox").hasDark, true); + assert.equal(byName.get("gruvbox").hasLight, true); + + assert.ok(byName.has("solarized"), "solarized set 应存在"); + assert.equal(byName.get("solarized").hasDark, true); + assert.equal(byName.get("solarized").hasLight, false); + + // 单文件主题也成 set + assert.ok(byName.has("monokai")); +}); + +// ─── resolveTheme:配对变体 ───────────────────────────────────────────────── + +test("resolveTheme 解析 gruvbox dark 变体(vars 引用 + 亮度推断)", () => { + const t = resolveTheme("gruvbox", "dark"); + assert.ok(t, "应解析成功"); + assert.equal(t.isDark, true); + assert.equal(t.cssVars["--bg"], "#282828"); + // colors 里的 var 引用解析:accent: "orange" → vars.orange(经对比度保障 ≥4.5:1) + assert.ok(contrastRatio(t.cssVars["--accent"], t.cssVars["--bg"]) >= 4.5); + // border: "bg4" → vars.bg4(#7c6f64 在 bg 上 3.03:1,过 3.0 保障后不变) + assert.equal(t.cssVars["--border"], "#7c6f64"); + // colors.text 为空串 → 回落 vars.fg0 + assert.equal(t.cssVars["--text"], "#fbf1c7"); +}); + +test("resolveTheme 解析 gruvbox light 变体", () => { + const t = resolveTheme("gruvbox", "light"); + assert.ok(t); + assert.equal(t.isDark, false); + assert.equal(t.cssVars["--bg"], "#fbf1c7"); + assert.equal(t.cssVars["--text"], "#282828"); +}); + +test("resolveTheme 派生 --composer-focus-bg(聚焦态与主题协调)", () => { + const dark = resolveTheme("gruvbox", "dark"); + const light = resolveTheme("gruvbox", "light"); + assert.ok(dark); + assert.ok(light); + // dark 变体:聚焦背景 = 面板色提亮(#3c3836 向白方向) + const darkFocus = dark.cssVars["--composer-focus-bg"]; + const darkPanel = dark.cssVars["--bg-panel"]; + assert.ok(darkFocus.startsWith("#"), "dark 聚焦背景应为 hex"); + assert.ok(darkFocus > darkPanel, "dark 聚焦背景应亮于面板色"); + // light 变体:聚焦背景 = 面板色向白混合 + const lightFocus = light.cssVars["--composer-focus-bg"]; + const lightPanel = light.cssVars["--bg-panel"]; + assert.ok(lightFocus.startsWith("#"), "light 聚焦背景应为 hex"); + assert.ok(lightFocus > lightPanel, "light 聚焦背景应亮于面板色"); +}); + +test("resolveTheme 缺失变体时回退到相反变体", () => { + // solarized 只有 dark 文件:请求 light 应回退 dark 文件,极性由内容决定 + const t = resolveTheme("solarized", "light"); + assert.ok(t); + assert.equal(t.isDark, true); + assert.equal(t.cssVars["--bg"], "#002b36"); +}); + +// ─── resolveTheme:单文件主题 ────────────────────────────────────────────── + +test("resolveTheme 单文件主题(无后缀)优先于相反变体回退", () => { + // monokai.json 存在且 monokai-dark.json 不存在:dark/light 请求都走单文件 + const dark = resolveTheme("monokai", "dark"); + const light = resolveTheme("monokai", "light"); + assert.ok(dark); + assert.ok(light); + assert.equal(dark.cssVars["--bg"], "#272822"); + assert.equal(light.cssVars["--bg"], "#272822"); +}); + +// ─── resolveTheme:不存在与非法输入 ───────────────────────────────────────── + +test("resolveTheme 不存在的主题返回 null", () => { + assert.equal(resolveTheme("nonexistent", "dark"), null); + assert.equal(resolveTheme("", "dark"), null); +}); + +// ─── 文字对比度保障(web 可读性) ───────────────────────────────────────────── + +test("文字层级对比度保障:muted ≥ 4.5、dim ≥ 3、dim 弱于 muted", () => { + // solarized dark 原版 muted/dim 对比不足(2.79/3.37),映射层应提升 muted; + // gruvbox light 原版 dim 3.24 接近下限,应保持或微调。 + const solarizedDark = resolveTheme("solarized", "dark"); + const gruvboxLight = resolveTheme("gruvbox", "light"); + assert.ok(solarizedDark); + assert.ok(gruvboxLight); + + for (const resolved of [solarizedDark, gruvboxLight]) { + const bg = resolved.cssVars["--bg"]; + const textRatio = contrastRatio(resolved.cssVars["--text"], bg); + const mutedRatio = contrastRatio(resolved.cssVars["--text-muted"], bg); + const dimRatio = contrastRatio(resolved.cssVars["--text-dim"], bg); + assert.ok(textRatio >= 4.5, `text 对比度应 ≥4.5(实际 ${textRatio.toFixed(2)})`); + assert.ok(mutedRatio >= 4.5, `muted 对比度应 ≥4.5(实际 ${mutedRatio.toFixed(2)})`); + assert.ok(dimRatio >= 3.0, `dim 对比度应 ≥3.0(实际 ${dimRatio.toFixed(2)})`); + assert.ok(dimRatio < mutedRatio, "dim 应弱于 muted(层级递减)"); + } +}); + +// ─── 层级适配(高级 UI 评审整改项) ────────────────────────────────────────── + +test("层级适配:accent 系 ≥4.5、border ≥3、气泡/侧边栏/选中态分层", () => { + for (const [name, mode] of [ + ["gruvbox", "dark"], + ["gruvbox", "light"], + ["solarized", "dark"], + ["solarized", "light"], + ]) { + const resolved = resolveTheme(name, mode); + assert.ok(resolved, `${name}/${mode} 应解析成功`); + const vars = resolved.cssVars; + const bg = vars["--bg"]; + + // 静止态 accent 与语法高亮语义色:全部 ≥4.5:1 + for (const key of ["--accent", "--accent-blue", "--accent-green", "--accent-orange", "--accent-red"]) { + assert.ok(contrastRatio(vars[key], bg) >= 4.5, `${name}/${mode} ${key} ≥4.5(实际 ${contrastRatio(vars[key], bg).toFixed(2)})`); + } + // 边框分隔线可见性:≥3:1 + assert.ok(contrastRatio(vars["--border"], bg) >= 3.0, `${name}/${mode} border ≥3.0`); + // 用户气泡与面板分层(不再同色,可辨层级 ≥1.25:1) + const userVsPanel = contrastRatio(vars["--user-bg"], vars["--bg-panel"]); + assert.ok(userVsPanel >= 1.25, `${name}/${mode} 气泡与面板对比 ≥1.25(实际 ${userVsPanel.toFixed(2)})`); + // 侧边栏表面色存在且与主背景可辨(≥1.5:1) + assert.ok(vars["--bg-sidebar"], `${name}/${mode} 应有侧边栏色`); + const sidebarVsBg = contrastRatio(vars["--bg-sidebar"], bg); + assert.ok(sidebarVsBg >= 1.5, `${name}/${mode} 侧边栏与主背景对比 ≥1.5(实际 ${sidebarVsBg.toFixed(2)})`); + // 选中态与 hover 区分(accent 微染) + assert.notEqual(vars["--bg-selected"], vars["--bg-hover"], `${name}/${mode} 选中态应异于 hover`); + } +}); diff --git a/lib/theme.ts b/lib/theme.ts new file mode 100644 index 000000000..16e7bfc30 --- /dev/null +++ b/lib/theme.ts @@ -0,0 +1,541 @@ +/** + * Theme system for pi-web. + * + * Loads pi CLI theme JSON files (from ~/.pi/agent/themes/, .pi/themes/, etc.), + * resolves `vars` references, and maps the 51 pi CLI color tokens to pi-web's + * ~23 CSS custom properties. + * + * Themes are organized as **sets** — each set pairs a dark and a light variant + * (e.g. "gruvbox" → gruvbox-dark.json + gruvbox-light.json). A set may also + * contain only one variant (single-file theme). + * + * pi CLI theme format: + * { name, vars: { key: hex|number, ... }, colors: { token: hex|number|varRef|"", ... } } + * + * Color values can be: + * - Hex string: "#ff0000" + * - 256-color index: 242 + * - Variable reference: "primary" (resolved from vars) + * - Empty string "": terminal default (we derive from palette) + */ + +import { readFileSync, readdirSync, existsSync, statSync } from "fs"; +import { join, basename, extname } from "path"; +import { homedir } from "os"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface PiTheme { + name: string; + vars?: Record; + colors: Record; +} + +/** Represents a paired theme set (e.g. "gruvbox" with dark + light variants). */ +export interface ThemeSetInfo { + /** Base name (e.g. "gruvbox") — used as the stable identifier. */ + name: string; + /** Human-readable display name. */ + displayName: string; + /** Whether this set has a dark variant. */ + hasDark: boolean; + /** Whether this set has a light variant. */ + hasLight: boolean; +} + +/** A resolved, ready-to-use theme (one variant of a set). */ +export interface ResolvedTheme { + /** Base theme-set name. */ + name: string; + /** Whether this specific variant is dark. */ + isDark: boolean; + /** CSS variable name → hex value (e.g. "--bg" → "#282828") */ + cssVars: Record; +} + +export type ThemeVariant = "dark" | "light"; + +// ─── 256-color palette → hex ──────────────────────────────────────────────── + +// Standard xterm 256-color palette. 0-15: ANSI, 16-231: 6x6x6 cube, 232-255: grayscale. +function ansiToHex(code: number): string { + // 0-15: basic ANSI colors + const ansi: Record = { + 0: "#000000", 1: "#800000", 2: "#008000", 3: "#808000", + 4: "#000080", 5: "#800080", 6: "#008080", 7: "#c0c0c0", + 8: "#808080", 9: "#ff0000", 10: "#00ff00", 11: "#ffff00", + 12: "#0000ff", 13: "#ff00ff", 14: "#00ffff", 15: "#ffffff", + }; + if (code in ansi) return ansi[code]; + + // 16-231: 6×6×6 RGB cube + if (code >= 16 && code <= 231) { + const n = code - 16; + const r = Math.round((Math.floor(n / 36) % 6) * (255 / 5)); + const g = Math.round((Math.floor(n / 6) % 6) * (255 / 5)); + const b = Math.round((n % 6) * (255 / 5)); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; + } + + // 232-255: grayscale ramp + if (code >= 232 && code <= 255) { + const v = Math.round(((code - 232) / 23) * 255); + const h = v.toString(16).padStart(2, "0"); + return `#${h}${h}${h}`; + } + + return "#000000"; +} + +// ─── Color resolution ─────────────────────────────────────────────────────── + +/** + * Resolve a single color value to a hex string. + * - Hex string: returned as-is (lowercased) + * - Number: treated as 256-color index, converted to hex + * - String matching a var name: resolved from vars + * - Empty string: returns empty (caller should substitute default) + */ +function resolveColor( + value: string | number | undefined, + vars: Record, +): string { + if (value === undefined || value === null) return ""; + if (typeof value === "number") return ansiToHex(value); + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed === "") return ""; + if (trimmed.startsWith("#")) return trimmed.toLowerCase(); + // Variable reference + if (vars[trimmed]) return vars[trimmed].toLowerCase(); + // Could be a raw number-as-string: "242" + const num = Number(trimmed); + if (!isNaN(num) && trimmed === String(num)) return ansiToHex(num); + // Unknown reference — return as-is (may be valid hex without #) + if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed.toLowerCase()}`; + return trimmed.toLowerCase(); + } + return ""; +} + +/** Resolve all `vars` entries to hex strings. */ +function resolveVars(vars: Record | undefined): Record { + const resolved: Record = {}; + if (!vars) return resolved; + for (const [key, value] of Object.entries(vars)) { + resolved[key] = resolveColor(value, {}); + } + return resolved; +} + +/** Resolve all `colors` entries, expanding var references. */ +function resolveColors( + colors: Record, + vars: Record, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(colors)) { + resolved[key] = resolveColor(value, vars); + } + return resolved; +} + +// ─── Color manipulation helpers ───────────────────────────────────────────── + +function hexToRgb(hex: string): [number, number, number] | null { + const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/.exec(hex); + if (!m) return null; + return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]; +} + +function rgbToHex(r: number, g: number, b: number): string { + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +/** Lighten a hex color by mixing with white. factor 0 = no change, 1 = white. */ +function lighten(hex: string, factor: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const [r, g, b] = rgb; + return rgbToHex( + Math.round(r + (255 - r) * factor), + Math.round(g + (255 - g) * factor), + Math.round(b + (255 - b) * factor), + ); +} + +/** Darken a hex color by mixing with black. factor 0 = no change, 1 = black. */ +function darken(hex: string, factor: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const [r, g, b] = rgb; + return rgbToHex( + Math.round(r * (1 - factor)), + Math.round(g * (1 - factor)), + Math.round(b * (1 - factor)), + ); +} + +/** Mix two hex colors. factor 0 = all a, factor 1 = all b. */ +function mix(a: string, b: string, factor: number): string { + const ra = hexToRgb(a); + const rb = hexToRgb(b); + if (!ra || !rb) return a; + return rgbToHex( + Math.round(ra[0] + (rb[0] - ra[0]) * factor), + Math.round(ra[1] + (rb[1] - ra[1]) * factor), + Math.round(ra[2] + (rb[2] - ra[2]) * factor), + ); +} + +/** Calculate relative luminance (0-1). Used to determine dark vs light. */ +function relativeLuminance(hex: string): number { + const rgb = hexToRgb(hex); + if (!rgb) return 0.5; + const [rs, gs, bs] = rgb.map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; +} + +function contrastRatio(foreground: string, background: string): number { + const foregroundLum = relativeLuminance(foreground); + const backgroundLum = relativeLuminance(background); + return (Math.max(foregroundLum, backgroundLum) + 0.05) / (Math.min(foregroundLum, backgroundLum) + 0.05); +} + +/** + * Preserve a theme status color's hue while making a modest contrast adjustment. + * Git status has redundant text, dot, and capsule-background signals, so 3:1 + * keeps green and yellow distinguishable in light themes better than forcing + * every status color to normal-text 4.5:1 contrast. + */ +function ensureContrast(color: string, background: string, minimum = 3): string { + if (!hexToRgb(color) || !hexToRgb(background) || contrastRatio(color, background) >= minimum) { + return color; + } + + const darkenForContrast = relativeLuminance(background) > relativeLuminance(color); + for (let step = 1; step <= 20; step += 1) { + const candidate = darkenForContrast + ? darken(color, step * 0.05) + : lighten(color, step * 0.05); + if (contrastRatio(candidate, background) >= minimum) return candidate; + } + return darkenForContrast ? "#000000" : "#ffffff"; +} + +// ─── pi CLI token → CSS variable mapping ──────────────────────────────────── + +/** + * Maps resolved pi CLI theme colors + vars to pi-web CSS custom properties. + */ +function mapToCssVars( + colors: Record, + vars: Record, +): Record { + // ── Extract base palette from vars ── + const bg0 = vars.bg0 || "#1a1a1a"; + const bg1 = vars.bg1 || "#242424"; + const bg2 = vars.bg2 || "#2e2e2e"; + const bg3 = vars.bg3 || "#383838"; + const fg0 = vars.fg0 || "#e8e8e8"; + const fg3 = vars.fg3 || "#888888"; + const fg4 = vars.fg4 || "#555555"; + + // Semantic palette colors + const red = vars.red || "#dc2626"; + const green = vars.green || "#16a34a"; + const orange = vars.orange || "#d97706"; + + // ── Resolve key pi CLI tokens ── + const accent = colors.accent || orange; + const text = colors.text || fg0; + const muted = colors.muted || fg3; + const dim = colors.dim || fg4; + const border = colors.border || bg3; + const success = colors.success || green; + const error = colors.error || red; + const warning = colors.warning || orange; + const toolSuccessBg = colors.toolSuccessBg || bg1; + + // Determine if dark theme + const isDark = relativeLuminance(bg0) < 0.5; + + // ── Web 适配:强调色 / 边框对比度保障 ── + // 静止态 accent(链接/图标/按钮)与语法高亮共用 --accent-*,全部过 + // ensureContrast(≥4.5:1),保留色相只调深浅;边框分隔线 ≥3:1。 + const accentSafe = ensureContrast(accent, bg0, 4.5); + const borderSafe = ensureContrast(border, bg0, 3.0); + + // ── Build CSS variables ── + const css: Record = {}; + + // Core backgrounds + css["--bg"] = bg0; + css["--bg-panel"] = bg1; + css["--bg-hover"] = bg2; + // 选中态:bg2 微染 accent 色相(低浓度),与 hover 区分且带主题气质 + css["--bg-selected"] = mix(bg2, accentSafe, 0.06); + // 侧边栏专用表面色:向 bg3 加深半档,避免与主区"融为一片" + // (实测 bg2 与主背景仅 1.3:1,人眼难辨,需 ~1.7:1+) + css["--bg-sidebar"] = mix(bg2, bg3, 0.5); + css["--bg-subtle"] = isDark + ? `rgba(255,255,255,0.035)` + : `rgba(15,23,42,0.035)`; + + // Borders + css["--border"] = borderSafe; + + // Text — web 可读性保障:主题色对比度不足时向正确方向微调, + // 正文/次级 ≥ 4.5:1、弱化层 ≥ 3:1,且层级严格递减(dim < muted < text)。 + // 终端主题(如 solarized)的低对比哲学在小字号 UI 上不适用,这里做适配。 + css["--text"] = ensureContrast(text, bg0, 4.5); + css["--text-muted"] = ensureContrast(muted, bg0, 4.5); + css["--text-dim"] = ensureContrast(dim, bg0, 3.0); + + // Accent — 静止态与语法高亮共用,全部保障 ≥4.5:1 + css["--accent"] = accentSafe; + css["--accent-hover"] = isDark ? lighten(accentSafe, 0.2) : darken(accentSafe, 0.15); + css["--accent-blue"] = ensureContrast(vars.blue || accent, bg0, 4.5); + + // Semantic colors + css["--accent-red"] = ensureContrast(error, bg0, 4.5); + css["--accent-green"] = ensureContrast(success, bg0, 4.5); + css["--accent-orange"] = ensureContrast(warning, bg0, 4.5); + + // Message bubbles — 用户气泡向 bg3 加深半档(~1.3:1),配 accent 描边, + // 与面板拉开可辨层级,消息归属清晰 + css["--user-bg"] = mix(bg2, bg3, 0.5); + css["--assistant-bg"] = bg0; + css["--tool-bg"] = toolSuccessBg; + + // Kabochar 特有:输入框聚焦背景(.chat-composer:focus-within)。 + // light 从主背景向白派生(聚焦更亮),dark 从面板提亮。 + css["--composer-focus-bg"] = isDark + ? lighten(bg1, 0.08) + : mix(bg0, "#ffffff", 0.35); + + return css; +} + +// ─── Theme loading ────────────────────────────────────────────────────────── + +/** All required pi CLI color tokens (51 tokens). */ +const ALL_COLOR_TOKENS = [ + "accent", "border", "borderAccent", "borderMuted", + "success", "error", "warning", "muted", "dim", "text", "thinkingText", + "selectedBg", "userMessageBg", "userMessageText", + "customMessageBg", "customMessageText", "customMessageLabel", + "toolPendingBg", "toolSuccessBg", "toolErrorBg", "toolTitle", "toolOutput", + "mdHeading", "mdLink", "mdLinkUrl", "mdCode", "mdCodeBlock", + "mdCodeBlockBorder", "mdQuote", "mdQuoteBorder", "mdHr", "mdListBullet", + "toolDiffAdded", "toolDiffRemoved", "toolDiffContext", + "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", + "syntaxString", "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation", + "thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", + "thinkingHigh", "thinkingXhigh", "thinkingMax", + "bashMode", +]; + +/** + * Parse a pi CLI theme JSON file. + * Validates required fields and fills in missing color tokens with empty strings. + */ +function parseThemeFile(path: string): PiTheme | null { + try { + const raw = readFileSync(path, "utf-8"); + const json = JSON.parse(raw); + + if (!json.name || typeof json.name !== "string") return null; + if (!json.colors || typeof json.colors !== "object") return null; + + // Fill missing tokens with empty strings + const colors: Record = {}; + for (const token of ALL_COLOR_TOKENS) { + colors[token] = json.colors[token] ?? ""; + } + + return { + name: json.name, + vars: json.vars, + colors, + }; + } catch { + return null; + } +} + +// ─── File-name convention helpers ─────────────────────────────────────────── + +/** + * Detect the base name and variant from a theme filename. + * + * Convention: + * gruvbox-dark.json → { base: "gruvbox", variant: "dark" } + * gruvbox-light.json → { base: "gruvbox", variant: "light" } + * monokai.json → { base: "monokai", variant: null } + */ +function parseThemeFilename( + filename: string, +): { base: string; variant: ThemeVariant | null } { + const stem = basename(filename, extname(filename)); + + // Try "-dark" / "-light" suffix (case-insensitive) + const darkMatch = /^(.+)-dark$/i.exec(stem); + if (darkMatch) return { base: darkMatch[1], variant: "dark" }; + + const lightMatch = /^(.+)-light$/i.exec(stem); + if (lightMatch) return { base: lightMatch[1], variant: "light" }; + + // Single-file theme — variant determined from content later + return { base: stem, variant: null }; +} + +/** + * Scan a directory for pi CLI theme JSON files. + * Returns an array of { path, base, variant, isDark } records. + */ +interface ScannedFile { + path: string; + base: string; + variant: ThemeVariant | null; + isDark: boolean; +} + +function scanThemeDir(dir: string): ScannedFile[] { + const results: ScannedFile[] = []; + try { + if (!existsSync(dir)) return results; + const entries = readdirSync(dir); + for (const entry of entries) { + if (extname(entry) !== ".json") continue; + const fullPath = join(dir, entry); + try { + if (!statSync(fullPath).isFile()) continue; + } catch { + continue; + } + const parsed = parseThemeFilename(entry); + // Determine actual polarity from file content + const theme = parseThemeFile(fullPath); + if (!theme) continue; + const vars = resolveVars(theme.vars); + const bg0 = vars.bg0 || "#1a1a1a"; + const isDark = relativeLuminance(bg0) < 0.5; + // If variant wasn't detected from filename, infer from content + const variant = parsed.variant ?? (isDark ? "dark" : "light"); + + results.push({ path: fullPath, base: parsed.base, variant, isDark }); + } + } catch { + // Permission errors, etc. + } + return results; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** List all available theme sets from the global theme directory. */ +export function listThemeSets(): ThemeSetInfo[] { + const result: ThemeSetInfo[] = []; + const seen = new Set(); + + // Collect all scanned files + const allFiles: ScannedFile[] = []; + + // Global themes: ~/.pi/agent/themes/ + const globalDir = join(homedir(), ".pi", "agent", "themes"); + allFiles.push(...scanThemeDir(globalDir)); + + // Group by base name + const groups = new Map(); + for (const f of allFiles) { + const list = groups.get(f.base) || []; + list.push(f); + groups.set(f.base, list); + } + + // Build ThemeSetInfo for each group + for (const [base, files] of groups) { + if (seen.has(base)) continue; + seen.add(base); + + let hasDark = false; + let hasLight = false; + for (const f of files) { + if (f.variant === "dark") hasDark = true; + if (f.variant === "light") hasLight = true; + } + + result.push({ + name: base, + displayName: themeNameToDisplay(base), + hasDark, + hasLight, + }); + } + + return result; +} + +/** Convert a kebab-case theme name to a display-friendly title. */ +function themeNameToDisplay(name: string): string { + return name + .split(/[-_]/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +/** + * Resolve a specific variant of a theme set. + * + * Lookup order: + * 1. `{base}-{variant}.json` (e.g. `gruvbox-dark.json`) + * 2. `{base}.json` (single-file fallback) + * 3. The opposite variant (if only one variant exists and user requests the other) + * + * @param name Base theme-set name (e.g. "gruvbox"). + * @param variant Which variant to load ("dark" or "light"). + */ +export function resolveTheme( + name: string, + variant: ThemeVariant, +): ResolvedTheme | null { + if (!name) return null; + + // Global themes: ~/.pi/agent/themes/ + const dirs: string[] = [ + join(homedir(), ".pi", "agent", "themes"), + ]; + + // Candidate filenames in priority order + const candidates = [ + `${name}-${variant}.json`, // e.g. gruvbox-dark.json + `${name}.json`, // e.g. monokai.json (single-file) + `${name}-${variant === "dark" ? "light" : "dark"}.json`, // opposite variant fallback + ]; + + for (const dir of dirs) { + for (const candidate of candidates) { + const fullPath = join(dir, candidate); + if (!existsSync(fullPath)) continue; + const theme = parseThemeFile(fullPath); + if (!theme) continue; + + const vars = resolveVars(theme.vars); + const colors = resolveColors(theme.colors, vars); + const cssVars = mapToCssVars(colors, vars); + const bg0 = vars.bg0 || "#1a1a1a"; + + return { + name, // Use the base name, not the file's internal name + isDark: relativeLuminance(bg0) < 0.5, + cssVars, + }; + } + } + + return null; +}