Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Open settings with the **gear icon** in the header. The dialog has three tabs: *

The sun/moon toggle in the header switches between **Dark**, **Light**, and **System** themes. System follows your OS preference and updates automatically. Dark is the default.

The **Theme** tab in Settings assigns a palette to each half of a pair: one theme for light mode, one for dark mode. A Light/Dark switch above the grid decides which half you are assigning, and the grid then lists only the palettes that can render it (Kanagawa Wave appears under Dark, Kanagawa Lotus under Light, and a palette that ships both variants appears under each with that mode's colors). The summary line above the grid always names both halves, and clicking either side jumps the grid to it.

With **System** selected, your two choices swap as your OS switches between light and dark. All three mode buttons stay available whatever you pick, because a dark-only palette simply never occupies the light half. The pair is saved to `~/.plannotator/config.json` under `theme`, so it carries across sessions and hosts.

## General

### Identity
Expand Down
3 changes: 2 additions & 1 deletion apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,10 +618,11 @@ export async function startAnnotateServer(options: {
handleShareHtml(res, url);
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; conventionalComments?: boolean };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
json(res, { ok: true });
Expand Down
3 changes: 2 additions & 1 deletion apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,11 @@ export async function startPlanReviewServer(options: {
});
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder;
Expand Down
3 changes: 2 additions & 1 deletion apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2476,10 +2476,11 @@ export async function startReviewServer(options: {
}
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; conventionalComments?: boolean };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
json(res, { ok: true });
Expand Down
11 changes: 11 additions & 0 deletions packages/core/config-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
export type DefaultDiffType = 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all';
export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong';

/**
* The user's appearance choice: a palette for the light half, a palette for
* the dark half, and which of them the mode selects. `system` follows the OS,
* so the two halves swap with `prefers-color-scheme`.
*/
export interface ThemeConfig {
mode?: 'light' | 'dark' | 'system';
light?: string;
dark?: string;
}

export interface DiffOptions {
diffStyle?: 'split' | 'unified';
overflow?: 'scroll' | 'wrap';
Expand Down
44 changes: 18 additions & 26 deletions packages/review-editor/components/ReviewHeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
} from '@plannotator/ui/components/ActionMenu';
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
import { THEME_MODES } from '@plannotator/ui/components/themeModes';
import { isThemeModeAvailable } from '@plannotator/ui/utils/themeRegistry';
import { MenuVersionSection } from '@plannotator/ui/components/MenuVersionSection';
import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon';
import { TextShimmer } from '@plannotator/ui/components/TextShimmer';
Expand Down Expand Up @@ -46,7 +45,7 @@ export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
origin,
isWSL = false,
}) => {
const { theme, setTheme, colorTheme } = useTheme();
const { theme, setTheme } = useTheme();

const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed;

Expand Down Expand Up @@ -87,30 +86,23 @@ export const ReviewHeaderMenu: React.FC<ReviewHeaderMenuProps> = ({
<div className="px-3 py-2 space-y-1.5">
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
{THEME_MODES.map(({ id, label, Icon }) => {
const available = isThemeModeAvailable(colorTheme, id);
return (
<button
key={id}
disabled={!available}
title={available ? undefined : 'Not supported by the current color theme'}
onClick={() => {
closeMenu();
setTheme(id);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
!available
? 'cursor-not-allowed text-muted-foreground opacity-40'
: theme === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon />
<span>{label}</span>
</button>
);
})}
{THEME_MODES.map(({ id, label, Icon }) => (
<button
key={id}
onClick={() => {
closeMenu();
setTheme(id);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
theme === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon />
<span>{label}</span>
</button>
))}
</div>
</div>

Expand Down
3 changes: 2 additions & 1 deletion packages/server/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,10 +562,11 @@ export async function startAnnotateServer(
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
Expand Down
4 changes: 4 additions & 0 deletions packages/server/goal-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export async function startGoalSetupServer(
const body = (await req.json()) as {
displayName?: string;
diffOptions?: Record<string, unknown>;
theme?: Record<string, unknown>;
conventionalComments?: boolean;
conventionalLabels?: unknown[] | null;
};
Expand All @@ -142,6 +143,9 @@ export async function startGoalSetupServer(
if (body.diffOptions !== undefined) {
toSave.diffOptions = body.diffOptions;
}
if (body.theme !== undefined) {
toSave.theme = body.theme;
}
if (body.conventionalComments !== undefined) {
toSave.conventionalComments = body.conventionalComments;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,10 +321,11 @@ export async function startPlannotatorServer(
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean };
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null; pfmReminder?: boolean };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder;
Expand Down
3 changes: 2 additions & 1 deletion packages/server/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2543,10 +2543,11 @@ export async function startReviewServer(
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
Expand Down
17 changes: 15 additions & 2 deletions packages/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { getPlannotatorDataDir } from "./data-dir";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { execSync } from "child_process";

import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions } from '@plannotator/core/config-types';
export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions };
import type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig } from '@plannotator/core/config-types';
export type { DefaultDiffType, DiffLineBgIntensity, DiffOptions, ThemeConfig };

/** Single conventional comment label entry stored in config.json */
export interface CCLabelConfig {
Expand Down Expand Up @@ -87,6 +87,13 @@ export function mergePromptConfig(
export interface PlannotatorConfig {
displayName?: string;
diffOptions?: DiffOptions;
/**
* Appearance: which mode, plus the palette assigned to each half of the
* light/dark pair. Written by the UI through POST /api/config, so a choice
* made in one session is picked up by the next one (each hook invocation
* runs on its own random port).
*/
theme?: ThemeConfig;
prompts?: PromptConfig;
conventionalComments?: boolean;
/** null = explicitly cleared (use defaults), undefined = not set */
Expand Down Expand Up @@ -215,11 +222,15 @@ export function saveConfig(partial: Partial<PlannotatorConfig>): void {
const mergedDiffOptions = (current.diffOptions || partial.diffOptions)
? { ...current.diffOptions, ...partial.diffOptions }
: undefined;
const mergedTheme = (current.theme || partial.theme)
? { ...current.theme, ...partial.theme }
: undefined;
const mergedPrompts = mergePromptConfig(current.prompts, partial.prompts);
const merged = {
...current,
...partial,
diffOptions: mergedDiffOptions,
theme: mergedTheme,
prompts: mergedPrompts,
};
mkdirSync(CONFIG_DIR, { recursive: true });
Expand Down Expand Up @@ -249,6 +260,7 @@ export function detectGitUser(): string | null {
export function getServerConfig(gitUser: string | null): {
displayName?: string;
diffOptions?: DiffOptions;
theme?: ThemeConfig;
gitUser?: string;
conventionalComments?: boolean;
conventionalLabels?: CCLabelConfig[] | null;
Expand All @@ -257,6 +269,7 @@ export function getServerConfig(gitUser: string | null): {
return {
displayName: cfg.displayName,
diffOptions: cfg.diffOptions,
...(cfg.theme !== undefined && { theme: cfg.theme }),
gitUser: gitUser ?? undefined,
...(cfg.conventionalComments !== undefined && { conventionalComments: cfg.conventionalComments }),
...(cfg.conventionalLabels !== undefined && { conventionalLabels: cfg.conventionalLabels }),
Expand Down
36 changes: 14 additions & 22 deletions packages/ui/components/ModeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTheme } from './ThemeProvider';
import { THEME_MODES } from './themeModes';
import { isThemeModeAvailable } from '../utils/themeRegistry';

export function ModeToggle() {
const { theme, setTheme, colorTheme } = useTheme();
const { theme, setTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -50,26 +49,19 @@ export function ModeToggle() {

{isOpen && (
<div className="absolute right-0 mt-1 w-32 rounded-lg border border-border bg-popover shadow-xl z-50 overflow-hidden py-1">
{THEME_MODES.map(({ id, label }) => {
const available = isThemeModeAvailable(colorTheme, id);
return (
<button
key={id}
disabled={!available}
title={available ? undefined : 'Not supported by the current color theme'}
onClick={() => { setTheme(id); setIsOpen(false); }}
className={`w-full px-3 py-1.5 text-left text-xs transition-colors ${
!available
? 'cursor-not-allowed text-muted-foreground opacity-40'
: theme === id
? 'text-primary bg-primary/10 font-medium'
: 'text-popover-foreground hover:bg-muted'
}`}
>
{label}
</button>
);
})}
{THEME_MODES.map(({ id, label }) => (
<button
key={id}
onClick={() => { setTheme(id); setIsOpen(false); }}
className={`w-full px-3 py-1.5 text-left text-xs transition-colors ${
theme === id
? 'text-primary bg-primary/10 font-medium'
: 'text-popover-foreground hover:bg-muted'
}`}
>
{label}
</button>
))}
</div>
)}
</div>
Expand Down
44 changes: 18 additions & 26 deletions packages/ui/components/PlanHeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
} from './ActionMenu';
import { useTheme } from './ThemeProvider';
import { THEME_MODES } from './themeModes';
import { isThemeModeAvailable } from '../utils/themeRegistry';
import { ReviewAgentsIcon } from './ReviewAgentsIcon';
import { MenuVersionSection } from './MenuVersionSection';
import { TextShimmer } from './TextShimmer';
Expand Down Expand Up @@ -59,7 +58,7 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
bearConfigured,
octarineConfigured,
}) => {
const { theme, setTheme, colorTheme } = useTheme();
const { theme, setTheme } = useTheme();

const showUpdateDot = !!updateInfo?.updateAvailable && !updateInfo.dismissed;

Expand Down Expand Up @@ -103,30 +102,23 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
<div className="px-3 py-2 space-y-1.5">
<ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
<div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
{THEME_MODES.map(({ id, label, Icon }) => {
const available = isThemeModeAvailable(colorTheme, id);
return (
<button
key={id}
disabled={!available}
title={available ? undefined : 'Not supported by the current color theme'}
onClick={() => {
closeMenu();
setTheme(id);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
!available
? 'cursor-not-allowed text-muted-foreground opacity-40'
: theme === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon />
<span>{label}</span>
</button>
);
})}
{THEME_MODES.map(({ id, label, Icon }) => (
<button
key={id}
onClick={() => {
closeMenu();
setTheme(id);
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
theme === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon />
<span>{label}</span>
</button>
))}
</div>
</div>

Expand Down
Loading