diff --git a/src/App.tsx b/src/App.tsx index c3292c9af..66ca36fe2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import NewWorkspaceModal from "./components/NewWorkspaceModal"; import { AIView } from "./components/AIView"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { TipsCarousel } from "./components/TipsCarousel"; -import { usePersistedState } from "./hooks/usePersistedState"; +import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { useProjectManagement } from "./hooks/useProjectManagement"; import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement"; @@ -21,6 +21,12 @@ import { CommandPalette } from "./components/CommandPalette"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; import { useGitStatus } from "./hooks/useGitStatus"; +import type { ThinkingLevel } from "./types/thinking"; +import { CUSTOM_EVENTS } from "./constants/events"; +import { getThinkingLevelKey } from "./constants/storage"; + +const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; + // Global Styles with nice fonts const globalStyles = css` * { @@ -277,6 +283,51 @@ function AppInner() { close: closeCommandPalette, } = useCommandRegistry(); + const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => { + if (!workspaceId) { + return "off"; + } + + if (typeof window === "undefined" || !window.localStorage) { + return "off"; + } + + try { + const key = getThinkingLevelKey(workspaceId); + const stored = window.localStorage.getItem(key); + if (!stored || stored === "undefined") { + return "off"; + } + const parsed = JSON.parse(stored) as ThinkingLevel; + return THINKING_LEVELS.includes(parsed) ? parsed : "off"; + } catch (error) { + console.warn("Failed to read thinking level", error); + return "off"; + } + }, []); + + const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => { + if (!workspaceId) { + return; + } + + const normalized = THINKING_LEVELS.includes(level) ? level : "off"; + const key = getThinkingLevelKey(workspaceId); + + // Use the utility function which handles localStorage and event dispatch + // ThinkingProvider will pick this up via its listener + updatePersistedState(key, normalized); + + // Dispatch toast notification event for UI feedback + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, { + detail: { workspaceId, level: normalized }, + }) + ); + } + }, []); + const registerParamsRef = useRef(null); const openNewWorkspaceFromPalette = useCallback( @@ -343,6 +394,8 @@ function AppInner() { workspaceMetadata, selectedWorkspace, streamingModels, + getThinkingLevel: getThinkingLevelForWorkspace, + onSetThinkingLevel: setThinkingLevelFromPalette, onOpenNewWorkspaceModal: openNewWorkspaceFromPalette, onCreateWorkspace: createWorkspaceFromPalette, onSelectWorkspace: selectWorkspaceFromPalette, diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index f0ab2231f..31e39d116 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -13,6 +13,7 @@ import { ChatToggles } from "./ChatToggles"; import { use1MContext } from "@/hooks/use1MContext"; import { modeToToolPolicy } from "@/utils/ui/modeUtils"; import { ToggleGroup } from "./ToggleGroup"; +import { CUSTOM_EVENTS } from "@/constants/events"; import type { UIMode } from "@/types/mode"; import { getSlashCommandSuggestions, @@ -25,6 +26,8 @@ import { ModelSelector, type ModelSelectorRef } from "./ModelSelector"; import { useModelLRU } from "@/hooks/useModelLRU"; import { VimTextArea } from "./VimTextArea"; +import type { ThinkingLevel } from "@/types/thinking"; + const InputSection = styled.div` position: relative; padding: 5px 15px 15px 15px; /* Reduced top padding from 15px to 5px */ @@ -399,8 +402,9 @@ export const ChatInput: React.FC = ({ setInput(detail.text); setTimeout(() => inputRef.current?.focus(), 0); }; - window.addEventListener("cmux:insertToChatInput", handler as EventListener); - return () => window.removeEventListener("cmux:insertToChatInput", handler as EventListener); + window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener); + return () => + window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener); }, [setInput]); // Allow external components to open the Model Selector @@ -409,10 +413,39 @@ export const ChatInput: React.FC = ({ // Open the inline ModelSelector and let it take focus itself modelSelectorRef.current?.open(); }; - window.addEventListener("cmux:openModelSelector", handler as EventListener); - return () => window.removeEventListener("cmux:openModelSelector", handler as EventListener); + window.addEventListener(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR, handler as EventListener); + return () => + window.removeEventListener(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR, handler as EventListener); }, []); + // Show toast when thinking level is changed via command palette + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ workspaceId: string; level: ThinkingLevel }>).detail; + if (!detail || detail.workspaceId !== workspaceId || !detail.level) { + return; + } + + const level = detail.level; + const levelDescriptions: Record = { + off: "Off — fastest responses", + low: "Low — adds light reasoning", + medium: "Medium — balanced reasoning", + high: "High — maximum reasoning depth", + }; + + setToast({ + id: Date.now().toString(), + type: "success", + message: `Thinking effort set to ${levelDescriptions[level]}`, + }); + }; + + window.addEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); + return () => + window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener); + }, [workspaceId, setToast]); + // Handle command selection const handleCommandSelect = useCallback( (suggestion: SlashSuggestion) => { diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 68420e96d..f12637839 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -5,6 +5,7 @@ import { useCommandRegistry } from "@/contexts/CommandRegistryContext"; import type { CommandAction } from "@/contexts/CommandRegistryContext"; import { formatKeybind, KEYBINDS, isEditableElement, matchesKeybind } from "@/utils/ui/keybinds"; import { getSlashCommandSuggestions } from "@/utils/slashCommands/suggestions"; +import { CUSTOM_EVENTS } from "@/constants/events"; const Overlay = styled.div` position: fixed; @@ -254,7 +255,9 @@ export const CommandPalette: React.FC = ({ getSlashContext shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`, run: () => { const text = s.replacement; - window.dispatchEvent(new CustomEvent("cmux:insertToChatInput", { detail: { text } })); + window.dispatchEvent( + new CustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { detail: { text } }) + ); }, })), }, diff --git a/src/constants/events.ts b/src/constants/events.ts new file mode 100644 index 000000000..8b34cb7c7 --- /dev/null +++ b/src/constants/events.ts @@ -0,0 +1,30 @@ +/** + * Custom Event Constants + * These are window-level custom events used for cross-component communication + */ + +export const CUSTOM_EVENTS = { + /** + * Event to show a toast notification when thinking level changes + * Detail: { workspaceId: string, level: ThinkingLevel } + */ + THINKING_LEVEL_TOAST: "cmux:thinkingLevelToast", + + /** + * Event to insert text into the chat input + * Detail: { text: string } + */ + INSERT_TO_CHAT_INPUT: "cmux:insertToChatInput", + + /** + * Event to open the model selector + * No detail + */ + OPEN_MODEL_SELECTOR: "cmux:openModelSelector", +} as const; + +/** + * Helper to create a storage change event name for a specific key + * Used by usePersistedState for same-tab synchronization + */ +export const getStorageChangeEvent = (key: string): string => `storage-change:${key}`; diff --git a/src/constants/storage.ts b/src/constants/storage.ts new file mode 100644 index 000000000..eca706bfe --- /dev/null +++ b/src/constants/storage.ts @@ -0,0 +1,10 @@ +/** + * LocalStorage Key Constants and Helpers + * These keys are used for persisting state in localStorage + */ + +/** + * Helper to create a thinking level storage key for a workspace + * Format: "thinkingLevel:{workspaceId}" + */ +export const getThinkingLevelKey = (workspaceId: string): string => `thinkingLevel:${workspaceId}`; diff --git a/src/contexts/ThinkingContext.tsx b/src/contexts/ThinkingContext.tsx index a7d910228..a1ab67011 100644 --- a/src/contexts/ThinkingContext.tsx +++ b/src/contexts/ThinkingContext.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import React, { createContext, useContext } from "react"; import type { ThinkingLevel } from "@/types/thinking"; import { usePersistedState } from "@/hooks/usePersistedState"; +import { getThinkingLevelKey } from "@/constants/storage"; interface ThinkingContextType { thinkingLevel: ThinkingLevel; @@ -16,9 +17,11 @@ interface ThinkingProviderProps { } export const ThinkingProvider: React.FC = ({ workspaceId, children }) => { + const key = getThinkingLevelKey(workspaceId); const [thinkingLevel, setThinkingLevel] = usePersistedState( - `thinkingLevel:${workspaceId}`, - "off" + key, + "off", + { listener: true } // Listen for changes from command palette and other sources ); return ( diff --git a/src/hooks/usePersistedState.ts b/src/hooks/usePersistedState.ts index aee734fde..4c42217f7 100644 --- a/src/hooks/usePersistedState.ts +++ b/src/hooks/usePersistedState.ts @@ -1,8 +1,39 @@ import type { Dispatch, SetStateAction } from "react"; import { useState, useCallback, useEffect } from "react"; +import { getStorageChangeEvent } from "@/constants/events"; type SetValue = T | ((prev: T) => T); +/** + * Update a persisted state value from outside the hook. + * This is useful when you need to update state from a different component/context + * that doesn't have access to the setter (e.g., command palette updating workspace state). + * + * @param key - The same localStorage key used in usePersistedState + * @param value - The new value to set + */ +export function updatePersistedState(key: string, value: T): void { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + + try { + if (value === undefined || value === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(value)); + } + + // Dispatch custom event for same-tab synchronization + const customEvent = new CustomEvent(getStorageChangeEvent(key), { + detail: { key, newValue: value }, + }); + window.dispatchEvent(customEvent); + } catch (error) { + console.warn(`Error writing to localStorage key "${key}":`, error); + } +} + interface UsePersistedStateOptions { /** Enable listening to storage changes from other components/tabs */ listener?: boolean; @@ -85,7 +116,7 @@ export function usePersistedState( } // Dispatch custom event for same-tab synchronization - const customEvent = new CustomEvent(`storage-change:${key}`, { + const customEvent = new CustomEvent(getStorageChangeEvent(key), { detail: { key, newValue }, }); window.dispatchEvent(customEvent); @@ -137,8 +168,9 @@ export function usePersistedState( }; // Listen to both storage events (cross-tab) and custom events (same-tab) + const storageChangeEvent = getStorageChangeEvent(key); window.addEventListener("storage", handleStorageChange); - window.addEventListener(`storage-change:${key}`, handleStorageChange); + window.addEventListener(storageChangeEvent, handleStorageChange); return () => { // Cancel pending animation frame @@ -146,7 +178,7 @@ export function usePersistedState( cancelAnimationFrame(rafId); } window.removeEventListener("storage", handleStorageChange); - window.removeEventListener(`storage-change:${key}`, handleStorageChange); + window.removeEventListener(storageChangeEvent, handleStorageChange); }; }, [key, options?.listener]); diff --git a/src/utils/commands/sources.test.ts b/src/utils/commands/sources.test.ts index b601a0b35..0df9ef296 100644 --- a/src/utils/commands/sources.test.ts +++ b/src/utils/commands/sources.test.ts @@ -21,6 +21,8 @@ const mk = (over: Partial[0]> = {}) => { workspaceId: "w1", }, streamingModels: new Map(), + getThinkingLevel: () => "off", + onSetThinkingLevel: () => undefined, onCreateWorkspace: async () => { await Promise.resolve(); }, @@ -47,3 +49,24 @@ test("buildCoreSources includes create/switch workspace actions", () => { expect(titles.includes("Open Current Workspace in Terminal")).toBe(true); expect(titles.includes("Open Workspace in Terminal…")).toBe(true); }); + +test("buildCoreSources adds thinking effort command", () => { + const sources = mk({ getThinkingLevel: () => "medium" }); + const actions = sources.flatMap((s) => s()); + const thinkingAction = actions.find((a) => a.id === "thinking:set-level"); + + expect(thinkingAction).toBeDefined(); + expect(thinkingAction?.subtitle).toContain("Medium"); +}); + +test("thinking effort command submits selected level", async () => { + const onSetThinkingLevel = jest.fn(); + const sources = mk({ onSetThinkingLevel, getThinkingLevel: () => "low" }); + const actions = sources.flatMap((s) => s()); + const thinkingAction = actions.find((a) => a.id === "thinking:set-level"); + + expect(thinkingAction?.prompt).toBeDefined(); + await thinkingAction!.prompt!.onSubmit({ thinkingLevel: "high" }); + + expect(onSetThinkingLevel).toHaveBeenCalledWith("w1", "high"); +}); diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 1d0e8ceef..209993d7f 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -1,5 +1,8 @@ import type { CommandAction } from "@/contexts/CommandRegistryContext"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; +import type { ThinkingLevel } from "@/types/thinking"; +import { CUSTOM_EVENTS } from "@/constants/events"; + import type { ProjectConfig } from "@/config"; import type { WorkspaceMetadata } from "@/types/workspace"; @@ -14,6 +17,9 @@ export interface BuildSourcesParams { } | null; streamingModels: Map; // UI actions + getThinkingLevel: (workspaceId: string) => ThinkingLevel; + onSetThinkingLevel: (workspaceId: string, level: ThinkingLevel) => void; + onOpenNewWorkspaceModal: (projectPath: string) => void; onCreateWorkspace: (projectPath: string, branchName: string) => Promise; onSelectWorkspace: (sel: { @@ -34,6 +40,8 @@ export interface BuildSourcesParams { onOpenWorkspaceInTerminal: (workspacePath: string) => void; } +const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; + const section = { workspaces: "Workspaces", navigation: "Navigation", @@ -341,27 +349,80 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }); // Modes & Model - actions.push(() => [ - { - id: "mode:toggle", - title: "Toggle Plan/Exec Mode", - section: section.mode, - shortcutHint: formatKeybind(KEYBINDS.TOGGLE_MODE), - run: () => { - const ev = new KeyboardEvent("keydown", { key: "M", ctrlKey: true, shiftKey: true }); - window.dispatchEvent(ev); + actions.push(() => { + const list: CommandAction[] = [ + { + id: "mode:toggle", + title: "Toggle Plan/Exec Mode", + section: section.mode, + shortcutHint: formatKeybind(KEYBINDS.TOGGLE_MODE), + run: () => { + const ev = new KeyboardEvent("keydown", { key: "M", ctrlKey: true, shiftKey: true }); + window.dispatchEvent(ev); + }, }, - }, - { - id: "model:change", - title: "Change Model…", - section: section.mode, - shortcutHint: formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR), - run: () => { - window.dispatchEvent(new CustomEvent("cmux:openModelSelector")); + { + id: "model:change", + title: "Change Model…", + section: section.mode, + shortcutHint: formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR), + run: () => { + window.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR)); + }, }, - }, - ]); + ]; + + const selectedWorkspace = p.selectedWorkspace; + if (selectedWorkspace) { + const { workspaceId } = selectedWorkspace; + const levelDescriptions: Record = { + off: "Off — fastest responses", + low: "Low — add a bit of reasoning", + medium: "Medium — balanced reasoning", + high: "High — maximum reasoning depth", + }; + const currentLevel = p.getThinkingLevel(workspaceId); + + list.push({ + id: "thinking:set-level", + title: "Set Thinking Effort…", + subtitle: `Current: ${levelDescriptions[currentLevel] ?? currentLevel}`, + section: section.mode, + run: () => undefined, + prompt: { + title: "Select Thinking Effort", + fields: [ + { + type: "select", + name: "thinkingLevel", + label: "Thinking effort", + placeholder: "Choose effort level…", + getOptions: () => + THINKING_LEVELS.map((level) => ({ + id: level, + label: levelDescriptions[level], + keywords: [ + level, + levelDescriptions[level].toLowerCase(), + "thinking", + "reasoning", + ], + })), + }, + ], + onSubmit: (vals) => { + const rawLevel = vals.thinkingLevel; + const level = THINKING_LEVELS.includes(rawLevel as ThinkingLevel) + ? (rawLevel as ThinkingLevel) + : "off"; + p.onSetThinkingLevel(workspaceId, level); + }, + }, + }); + } + + return list; + }); // Help / Docs actions.push(() => [