diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts
index 57c1711de158abeb04d3df568c4070f43ef19ce5..9f927c81dbac0eb194c524c5845a7134181fab5b 100644
--- a/apps/web/src/components/CommandPalette.logic.ts
+++ b/apps/web/src/components/CommandPalette.logic.ts
@@ -61,7 +61,7 @@ export function browseInputEndPaddingClass(input: {
export type SearchOverlayMode = "command" | "files" | "content";
export type CommandPaletteOpenIntent =
- | { readonly kind: "add-project" | "new-thread-in" }
+ | { readonly kind: "add-project" | "new-thread-in" | "change-theme" }
| {
readonly kind: "search";
readonly query: string;
@@ -84,6 +84,7 @@ export type CommandPaletteUiAction =
}
| { readonly _tag: "OpenAddProject" }
| { readonly _tag: "OpenNewThreadIn" }
+ | { readonly _tag: "OpenChangeTheme" }
| { readonly _tag: "ClearOpenIntent" };
export function reduceCommandPaletteUiState(
@@ -113,6 +114,8 @@ export function reduceCommandPaletteUiState(
return { open: true, mode: "command", openIntent: { kind: "add-project" } };
case "OpenNewThreadIn":
return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } };
+ case "OpenChangeTheme":
+ return { open: true, mode: "command", openIntent: { kind: "change-theme" } };
case "ClearOpenIntent":
return state.openIntent ? { ...state, openIntent: null } : state;
}
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 0b244d133909706f3eba469ef572b17da9e336c1..ae373e65a3cc782ffdd00d7584a4d04b9ba8a20c 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -48,9 +48,12 @@ import {
FolderPlusIcon,
LinkIcon,
MessageSquareIcon,
+ MonitorIcon,
+ MoonIcon,
PaletteIcon,
SettingsIcon,
SquarePenIcon,
+ SunIcon,
TextSearchIcon,
} from "lucide-react";
import {
@@ -74,6 +77,15 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
import { useClientSettings } from "../hooks/useSettings";
import { useTheme } from "../hooks/useTheme";
+import { useCustomThemes } from "../hooks/useCustomThemes";
+import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme";
+import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
+import { getThemeDefinition } from "../themePalette";
+import {
+ STANDARD_THEME_CARDS,
+ getThemeCardDefinition,
+ ThemePreviewCircle,
+} from "./settings/ThemePreviewCircles";
import { readLocalApi } from "../localApi";
import { desktopLocalBackendId } from "../connection/desktopLocal";
import { filesystemEnvironment } from "../state/filesystem";
@@ -184,6 +196,22 @@ import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
+const APPEARANCE_OPTIONS = [
+ { mode: "system", label: "System", icon: MonitorIcon },
+ { mode: "light", label: "Light", icon: SunIcon },
+ { mode: "dark", label: "Dark", icon: MoonIcon },
+] as const;
+
+function notifyThemeSaveFailure(): void {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Couldn't save theme selection",
+ description: "Try again.",
+ }),
+ );
+}
+
function projectFavicon(project: Project) {
return ;
}
@@ -453,7 +481,7 @@ export function CommandPalette({ children }: { children: ReactNode }) {
const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []);
const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
- const { theme, themeHalves, resolvedTheme } = useTheme();
+ const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme();
const composerHandleRef = useRef(null);
const routeTarget = useParams({
strict: false,
@@ -494,8 +522,33 @@ export function CommandPalette({ children }: { children: ReactNode }) {
terminalOpen,
previewFocus: isPreviewFocused(),
previewOpen,
+ modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false,
},
});
+ if (command === "appearance.cycle") {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.repeat) return;
+ const nextMode =
+ appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system";
+ if (!setAppearanceMode(nextMode)) {
+ notifyThemeSaveFailure();
+ } else {
+ toastManager.add({
+ id: "appearance-cycle",
+ title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`,
+ timeout: 1500,
+ });
+ }
+ return;
+ }
+ if (command === "theme.select") {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.repeat) return;
+ dispatch({ _tag: "OpenChangeTheme" });
+ return;
+ }
if (command === "themeEditor.toggle") {
event.preventDefault();
event.stopPropagation();
@@ -516,7 +569,17 @@ export function CommandPalette({ children }: { children: ReactNode }) {
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
- }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]);
+ }, [
+ appearanceMode,
+ keybindings,
+ previewOpen,
+ resolvedTheme,
+ setAppearanceMode,
+ terminalOpen,
+ theme,
+ themeHalves,
+ toggleMode,
+ ]);
useEffect(
() =>
@@ -702,7 +765,30 @@ function OpenCommandPaletteDialog(props: {
const projectOrder = useUiStateStore((store) => store.projectOrder);
const threads = useThreadShells();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
- const { theme, themeHalves, resolvedTheme } = useTheme();
+ const {
+ theme,
+ themeHalves,
+ resolvedTheme,
+ appearanceMode,
+ setAppearanceMode,
+ setTheme,
+ setThemeHalf,
+ } = useTheme();
+ const customThemes = useCustomThemes();
+ const environmentThemes = useEnvironmentThemeDefinitions();
+ const themeCards = useMemo(() => {
+ const seen = new Set();
+ return [
+ ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })),
+ ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes]
+ .filter((definition) => {
+ if (seen.has(definition.id)) return false;
+ seen.add(definition.id);
+ return true;
+ })
+ .map(getThemeCardDefinition),
+ ];
+ }, [customThemes, environmentThemes]);
const providers = useAtomValue(primaryServerProvidersAtom);
const providerEntryByEnvironmentAndInstanceId = useMemo(() => {
const map = new Map();
@@ -1803,6 +1889,100 @@ function OpenCommandPaletteDialog(props: {
});
}
+ const changeThemeItem: CommandPaletteSubmenuItem = {
+ kind: "submenu",
+ value: "action:change-theme",
+ searchTerms: ["change theme", "appearance", "colors", "palette"],
+ title: "Change theme",
+ icon: ,
+ addonIcon: ,
+ shortcutCommand: "theme.select",
+ groups: [
+ {
+ value: "themes",
+ label: "Change theme",
+ items: themeCards.map(({ id, label, previews }) => ({
+ kind: "action",
+ value: id === null ? "theme:standard" : `theme:palette:${id}`,
+ title: label,
+ description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined,
+ searchTerms: [label, "theme", "appearance"],
+ icon: ,
+ titleTrailingContent: (
+
+ {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? (
+ Current
+ ) : null}
+
+ {previews.map((preview) => (
+
+ ))}
+
+
+ ),
+ run: async () => {
+ const saved =
+ previews.length === 1 && id !== null
+ ? setThemeHalf(previews[0]!.mode, id)
+ : setTheme(id ?? appearanceMode);
+ if (!saved) notifyThemeSaveFailure();
+ },
+ })),
+ },
+ ],
+ };
+ actionItems.push(changeThemeItem);
+
+ const changeAppearanceItem: CommandPaletteSubmenuItem = {
+ kind: "submenu",
+ value: "action:change-appearance",
+ searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"],
+ title: "Change appearance",
+ icon: ,
+ addonIcon: ,
+ shortcutCommand: "appearance.cycle",
+ groups: [
+ {
+ value: "appearance",
+ label: "Change appearance",
+ items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({
+ kind: "action",
+ value: `appearance:${mode}`,
+ title: label,
+ searchTerms: [label, "appearance", "mode"],
+ icon: ,
+ titleTrailingContent:
+ appearanceMode === mode ? (
+ Current
+ ) : undefined,
+ run: async () => {
+ if (!setAppearanceMode(mode)) notifyThemeSaveFailure();
+ },
+ })),
+ },
+ ],
+ };
+ actionItems.push(changeAppearanceItem);
+
+ useLayoutEffect(() => {
+ if (openIntent?.kind !== "change-theme") return;
+ clearOpenIntent();
+ browseNavigation.invalidate();
+ cloneLookupGeneration.current += 1;
+ setIsRemoteProjectLookingUp(false);
+ setAddProjectCloneFlow(null);
+ setViewStack([]);
+ pushPaletteView({
+ addonIcon: ,
+ groups: [{ value: "themes", label: "Change theme", items: [] }],
+ });
+ }, [browseNavigation, clearOpenIntent, openIntent, pushPaletteView]);
+
actionItems.push({
kind: "action",
value: "action:theme-editor",
@@ -1898,7 +2078,11 @@ function OpenCommandPaletteDialog(props: {
addProjectEnvironmentId,
buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data),
)
- : (currentView?.groups ?? rootGroups);
+ : currentView?.groups[0]?.value === "themes"
+ ? changeThemeItem.groups
+ : currentView?.groups[0]?.value === "appearance"
+ ? changeAppearanceItem.groups
+ : (currentView?.groups ?? rootGroups);
const filteredGroups = filterCommandPaletteGroups({
activeGroups,
diff --git a/apps/web/src/components/settings/ThemePreviewCircles.tsx b/apps/web/src/components/settings/ThemePreviewCircles.tsx
index 1ac8fa7349213fa62f97c6114fb30307cf9f59f6..65e5270e60b6a3566ed7dd5755e27c84149da1f2 100644
--- a/apps/web/src/components/settings/ThemePreviewCircles.tsx
+++ b/apps/web/src/components/settings/ThemePreviewCircles.tsx
@@ -131,14 +131,19 @@ function themePreviewEdgeShadow(mode: ThemeAppearance): string {
export function ThemePreviewCircle({
colors,
mode,
+ className,
}: {
colors: ThemeCardPreviewColors;
mode: ThemeAppearance;
+ className?: string;
}) {
return (
= [];
+
export function getCustomThemes(): ReadonlyArray {
const snapshot = getCustomThemeLibrarySnapshot();
- return snapshot.status === "ready" ? snapshot.themes : [];
+ return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES;
}
export function getEnvironmentThemes(): ReadonlyArray {
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index b2fdd05b08f951cc78906b8c078a7e12a33433dc..fc7842903e0fa18e33c35b5672da5b5598c08933 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -4,6 +4,12 @@ Open **Settings → Appearance** to choose a theme and follow the system appeara
or dark mode. To use different themes for light and dark mode, select the corresponding preview
within each theme. Appearance preferences are saved separately on each device or browser.
+On web and desktop, use **Change theme** in the command palette to select a theme without leaving chat.
+Press **Cmd+Option+A** on macOS or **Ctrl+Alt+A** on Windows/Linux to open the theme picker directly.
+Use **Change appearance** in the command palette to choose System, Light, or Dark independently of
+the theme. **Cmd+Option+Shift+A** on macOS or **Ctrl+Alt+Shift+A** on Windows/Linux cycles through
+those modes. Customize these shortcuts under **Settings → Keybindings**.
+
Mobile has its own themes and text, code, and terminal preferences. It does not follow environment
themes or defaults.
diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts
index 1256dcc77786154426ce776029574dd4f9a2e793..44d76ded1703426c96624bc4c9bbda46f8f35cb2 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -75,6 +75,8 @@ export const STATIC_KEYBINDING_COMMANDS = [
"commandPalette.toggle",
"filePicker.toggle",
"projectSearch.toggle",
+ "theme.select",
+ "appearance.cycle",
"themeEditor.toggle",
"composer.stash",
"composer.host",
diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts
index 0cc18680e6d509852f9248b666a5b21319e7389f..948212a9aac6539409f5d7d92460e0a15a5faf6a 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -38,6 +38,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [
{ key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
{ key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },
{ key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
+ { key: "mod+alt+a", command: "theme.select", when: "!terminalFocus" },
+ { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" },
{ key: "mod+alt+shift+t", command: "themeEditor.toggle" },
{ key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
{ key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },