1import type { AnyRouter } from "@tanstack/react-router";
2
3import {
4 dispatchCommandPalette,
5 isCommandPaletteMounted,
6 useCommandPaletteStore,
7} from "../commandPaletteStore";
8import { overlayModeForCommand } from "../components/CommandPalette.logic";
9import { APPEARANCE_OPTIONS, notifyThemeSaveFailure } from "../components/CommandPaletteThemeItems";
10import { toggleThemeEditorForTheme } from "../components/settings/themeEditorStore";
11import { toastManager } from "../components/ui/toast";
12import { getThemeSnapshot, setAppearanceMode } from "../hooks/useTheme";
13import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
14import { type CommandHandler, type CommandScopeOptions, registerCommands } from "./dispatcher";
15import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
16import { resolveThreadRouteTarget } from "../threadRoutes";
17
18function consume(event: KeyboardEvent): void {
19 event.preventDefault();
20 event.stopPropagation();
21}
22
23const toggleOverlay =
24 (command: Parameters<typeof overlayModeForCommand>[0]): CommandHandler =>
25 (event) => {
26 const mode = overlayModeForCommand(command);
27 if (mode === null) return false;
28 consume(event);
29 dispatchCommandPalette({ _tag: "ToggleMode", mode });
30 };
31
32/**
33 * Palette, theme and usage shortcuts. They read the route, panels and theme
34 * when the key is pressed, and are live only while the palette is mounted.
35 */
36export function installCommandPaletteCommands(
37 router: AnyRouter,
38 options: Pick<CommandScopeOptions, "keybindings"> = {},
39): () => void {
40 const readRouteThreadRef = () => {
41 const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
42 return target?.kind === "server" ? target.threadRef : null;
43 };
44
45 // A child search (files, contents) goes back to the palette on Escape before
46 // its own widgets or the dialog can treat the key as a close.
47 const disposeEscape = registerCommands(
48 {},
49 {
50 capture: true,
51 when: isCommandPaletteMounted,
52 onKeyDown: (event) => {
53 if (event.isComposing || event.keyCode === 229 || event.key !== "Escape") return false;
54 const { open, mode } = useCommandPaletteStore.getState();
55 if (!open || mode === "command") return false;
56 consume(event);
57 dispatchCommandPalette({ _tag: "BackToCommand" });
58 },
59 },
60 );
61
62 const disposeCommands = registerCommands(
63 {
64 "appearance.cycle": (event) => {
65 consume(event);
66 if (event.repeat) return;
67 const { appearanceMode } = getThemeSnapshot();
68 const nextMode =
69 appearanceMode === "system" ? "light" : appearanceMode === "light" ? "dark" : "system";
70 if (!setAppearanceMode(nextMode)) {
71 notifyThemeSaveFailure();
72 return;
73 }
74 toastManager.add({
75 id: "appearance-cycle",
76 title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`,
77 timeout: 1500,
78 });
79 },
80 "theme.select": (event) => {
81 consume(event);
82 if (event.repeat) return;
83 dispatchCommandPalette({ _tag: "OpenChangeTheme" });
84 },
85 "themeEditor.toggle": (event) => {
86 consume(event);
87 const { theme, themeHalves, resolvedTheme } = getThemeSnapshot();
88 toggleThemeEditorForTheme({ theme, themeHalves, initialAppearance: resolvedTheme });
89 },
90 "usage.open": (event) => {
91 consume(event);
92 dispatchCommandPalette({ _tag: "SetOpen", open: false });
93 void router.navigate({ to: "/usage" });
94 },
95 "commandPalette.toggle": toggleOverlay("commandPalette.toggle"),
96 "filePicker.toggle": toggleOverlay("filePicker.toggle"),
97 "projectSearch.toggle": toggleOverlay("projectSearch.toggle"),
98 },
99 {
100 ...options,
101 when: isCommandPaletteMounted,
102 context: () => {
103 const threadRef = readRouteThreadRef();
104 return {
105 terminalOpen: threadRef
106 ? selectThreadTerminalUiState(
107 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
108 threadRef,
109 ).terminalOpen
110 : false,
111 previewOpen: threadRef
112 ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) ===
113 "preview"
114 : false,
115 usagePageOpen: router.state.location.pathname === "/usage",
116 };
117 },
118 },
119 );
120
121 return () => {
122 disposeEscape();
123 disposeCommands();
124 };
125}