Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/api/themes/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
16 changes: 16 additions & 0 deletions app/api/themes/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
12 changes: 12 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default function RootLayout({
<meta name="google" content="notranslate" />
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("pi-theme");if(t==="dark")document.documentElement.classList.add("dark")}catch(e){}})();`,
__html: `(function(){try{var h=document.documentElement,t=localStorage.getItem("pi-theme");if(t==="dark"){h.classList.add("dark")}else if(t&&t!=="light"){h.dataset.theme=t;if(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)h.classList.add("dark")}}catch(e){}})();`,
}}
/>
</head>
Expand Down
154 changes: 141 additions & 13 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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();
Expand Down Expand Up @@ -145,6 +147,24 @@ export function AppShell() {
const chatInputRef = useRef<ChatInputHandle | null>(null);
const topBarRef = useRef<HTMLDivElement>(null);
const languageBtnRef = useRef<HTMLButtonElement>(null);
const themeBtnRef = useRef<HTMLButtonElement>(null);

// 主题集列表(来自 ~/.pi/agent/themes/ 的 pi theme JSON,经 /api/themes 发现)
const [themeSets, setThemeSets] = useState<ThemeSetInfo[]>([]);
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<SessionTreeNode[]>([]);
Expand Down Expand Up @@ -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]);
Expand All @@ -223,22 +243,30 @@ 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 });
};
update();
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]);

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -883,6 +911,32 @@ export function AppShell() {
</svg>
)}
</button>
<button
ref={themeBtnRef}
type="button"
onClick={() => 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)"; }}
>
{/* 调色板图标:主题集选择入口 */}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22a10 10 0 1 1 10-10c0 1.66-1.34 3-3 3h-2.5a2.5 2.5 0 0 0-1.9 4.1c.4.5.2 1.4-.6 1.9-.8.4-1.5.8-2 1Z" />
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" /><circle cx="11" cy="7.5" r="1" fill="currentColor" /><circle cx="16" cy="9.5" r="1" fill="currentColor" />
</svg>
</button>
<button
ref={languageBtnRef}
type="button"
Expand Down Expand Up @@ -1288,6 +1342,80 @@ export function AppShell() {
))}
</div>
)}
{activeTopPanel === "theme" && (
<div
role="menu"
aria-label={translate("theme.select")}
style={{
background: "var(--bg-panel)",
borderLeft: "1px solid var(--border)",
borderRight: "1px solid var(--border)",
borderBottom: "1px solid var(--border)",
overflow: "hidden",
padding: 4,
}}
>
<button
type="button"
onClick={() => {
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";
}}
>
<span>{translate("theme.default")}</span>
{themeName === "" && <span style={{ marginLeft: "auto", color: "var(--accent)" }}>✓</span>}
</button>
{themeSets.length === 0 ? (
<div style={{ padding: "8px 10px", fontSize: 12, color: "var(--text-dim)" }}>
{translate("theme.loading")}
</div>
) : (
themeSets.map((ts) => (
<button
key={ts.name}
type="button"
onClick={() => {
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";
}}
>
<span>{ts.displayName}</span>
{themeName === ts.name && <span style={{ marginLeft: "auto", color: "var(--accent)" }}>✓</span>}
</button>
))
)}
</div>
)}
{activeTopPanel === "system" && (
<div style={{
background: "var(--bg-panel)",
Expand Down
2 changes: 1 addition & 1 deletion components/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ function TreeNode({
aria-label={t("files.newlyUploaded")}
style={{ width: 14, height: 14, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#3b82f6" }} />
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)" }} />
</span>
)}
{!hovered && !node.isDir && gitStatus && (
Expand Down
7 changes: 2 additions & 5 deletions components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<FileData | null>(null);
const [gitDiff, setGitDiff] = useState<GitFileDiffResponse | null>(null);
Expand Down Expand Up @@ -1206,7 +1203,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL
<SyntaxHighlighter
className={wrapLines ? "file-source-view is-wrapped" : "file-source-view"}
language={language === "text" ? "plaintext" : language}
style={isDark ? vscDarkPlus : vs}
style={prismTheme}
showLineNumbers
lineNumberStyle={{
...FILE_LINE_NUMBER_STYLE,
Expand Down
6 changes: 2 additions & 4 deletions components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import { useEffect, useRef, useState, type ReactNode } from "react";
import { Prism as SyntaxHighlighter } 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 { useTheme } from "@/hooks/useTheme";
import { useI18n } from "@/hooks/useI18n";
import { copyText } from "@/lib/clipboard";
Expand Down Expand Up @@ -231,7 +230,6 @@ interface CodeBlockProps {
* Used as the "source" view for mermaid blocks and for all non-mermaid code fences.
*/
export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) {
const { isDark } = useTheme();
const { t } = useI18n();
const [copied, setCopied] = useState(false);

Expand All @@ -258,7 +256,7 @@ export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) {
</div>
<SyntaxHighlighter
language={lang || "text"}
style={isDark ? vscDarkPlus : vs}
style={prismTheme}
showLineNumbers
lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal" }}
customStyle={{
Expand Down
Loading