Skip to content

Commit 5bff20c

Browse files
authored
🤖 Add command palette control for thinking effort (#86)
## Summary - add a "Set Thinking Effort…" command palette action that offers off/low/medium/high options - persist and broadcast the selected thinking level so chat input stays in sync - confirm the behavior with unit coverage for the new command source ## Testing - bun typecheck _Generated with `cmux`_
1 parent 30d832f commit 5bff20c

File tree

9 files changed

+278
-30
lines changed

9 files changed

+278
-30
lines changed

‎src/App.tsx‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import NewWorkspaceModal from "./components/NewWorkspaceModal";
1010
import { AIView } from "./components/AIView";
1111
import { ErrorBoundary } from "./components/ErrorBoundary";
1212
import { TipsCarousel } from "./components/TipsCarousel";
13-
import { usePersistedState } from "./hooks/usePersistedState";
13+
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
1414
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
1515
import { useProjectManagement } from "./hooks/useProjectManagement";
1616
import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement";
@@ -21,6 +21,12 @@ import { CommandPalette } from "./components/CommandPalette";
2121
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
2222
import { useGitStatus } from "./hooks/useGitStatus";
2323

24+
import type { ThinkingLevel } from "./types/thinking";
25+
import { CUSTOM_EVENTS } from "./constants/events";
26+
import { getThinkingLevelKey } from "./constants/storage";
27+
28+
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
29+
2430
// Global Styles with nice fonts
2531
const globalStyles = css`
2632
* {
@@ -277,6 +283,51 @@ function AppInner() {
277283
close: closeCommandPalette,
278284
} = useCommandRegistry();
279285

286+
const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => {
287+
if (!workspaceId) {
288+
return "off";
289+
}
290+
291+
if (typeof window === "undefined" || !window.localStorage) {
292+
return "off";
293+
}
294+
295+
try {
296+
const key = getThinkingLevelKey(workspaceId);
297+
const stored = window.localStorage.getItem(key);
298+
if (!stored || stored === "undefined") {
299+
return "off";
300+
}
301+
const parsed = JSON.parse(stored) as ThinkingLevel;
302+
return THINKING_LEVELS.includes(parsed) ? parsed : "off";
303+
} catch (error) {
304+
console.warn("Failed to read thinking level", error);
305+
return "off";
306+
}
307+
}, []);
308+
309+
const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => {
310+
if (!workspaceId) {
311+
return;
312+
}
313+
314+
const normalized = THINKING_LEVELS.includes(level) ? level : "off";
315+
const key = getThinkingLevelKey(workspaceId);
316+
317+
// Use the utility function which handles localStorage and event dispatch
318+
// ThinkingProvider will pick this up via its listener
319+
updatePersistedState(key, normalized);
320+
321+
// Dispatch toast notification event for UI feedback
322+
if (typeof window !== "undefined") {
323+
window.dispatchEvent(
324+
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
325+
detail: { workspaceId, level: normalized },
326+
})
327+
);
328+
}
329+
}, []);
330+
280331
const registerParamsRef = useRef<BuildSourcesParams | null>(null);
281332

282333
const openNewWorkspaceFromPalette = useCallback(
@@ -343,6 +394,8 @@ function AppInner() {
343394
workspaceMetadata,
344395
selectedWorkspace,
345396
streamingModels,
397+
getThinkingLevel: getThinkingLevelForWorkspace,
398+
onSetThinkingLevel: setThinkingLevelFromPalette,
346399
onOpenNewWorkspaceModal: openNewWorkspaceFromPalette,
347400
onCreateWorkspace: createWorkspaceFromPalette,
348401
onSelectWorkspace: selectWorkspaceFromPalette,

‎src/components/ChatInput.tsx‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ChatToggles } from "./ChatToggles";
1313
import { use1MContext } from "@/hooks/use1MContext";
1414
import { modeToToolPolicy } from "@/utils/ui/modeUtils";
1515
import { ToggleGroup } from "./ToggleGroup";
16+
import { CUSTOM_EVENTS } from "@/constants/events";
1617
import type { UIMode } from "@/types/mode";
1718
import {
1819
getSlashCommandSuggestions,
@@ -25,6 +26,8 @@ import { ModelSelector, type ModelSelectorRef } from "./ModelSelector";
2526
import { useModelLRU } from "@/hooks/useModelLRU";
2627
import { VimTextArea } from "./VimTextArea";
2728

29+
import type { ThinkingLevel } from "@/types/thinking";
30+
2831
const InputSection = styled.div`
2932
position: relative;
3033
padding: 5px 15px 15px 15px; /* Reduced top padding from 15px to 5px */
@@ -399,8 +402,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
399402
setInput(detail.text);
400403
setTimeout(() => inputRef.current?.focus(), 0);
401404
};
402-
window.addEventListener("cmux:insertToChatInput", handler as EventListener);
403-
return () => window.removeEventListener("cmux:insertToChatInput", handler as EventListener);
405+
window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
406+
return () =>
407+
window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
404408
}, [setInput]);
405409

406410
// Allow external components to open the Model Selector
@@ -409,10 +413,39 @@ export const ChatInput: React.FC<ChatInputProps> = ({
409413
// Open the inline ModelSelector and let it take focus itself
410414
modelSelectorRef.current?.open();
411415
};
412-
window.addEventListener("cmux:openModelSelector", handler as EventListener);
413-
return () => window.removeEventListener("cmux:openModelSelector", handler as EventListener);
416+
window.addEventListener(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR, handler as EventListener);
417+
return () =>
418+
window.removeEventListener(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR, handler as EventListener);
414419
}, []);
415420

421+
// Show toast when thinking level is changed via command palette
422+
useEffect(() => {
423+
const handler = (event: Event) => {
424+
const detail = (event as CustomEvent<{ workspaceId: string; level: ThinkingLevel }>).detail;
425+
if (!detail || detail.workspaceId !== workspaceId || !detail.level) {
426+
return;
427+
}
428+
429+
const level = detail.level;
430+
const levelDescriptions: Record<ThinkingLevel, string> = {
431+
off: "Off — fastest responses",
432+
low: "Low — adds light reasoning",
433+
medium: "Medium — balanced reasoning",
434+
high: "High — maximum reasoning depth",
435+
};
436+
437+
setToast({
438+
id: Date.now().toString(),
439+
type: "success",
440+
message: `Thinking effort set to ${levelDescriptions[level]}`,
441+
});
442+
};
443+
444+
window.addEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener);
445+
return () =>
446+
window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener);
447+
}, [workspaceId, setToast]);
448+
416449
// Handle command selection
417450
const handleCommandSelect = useCallback(
418451
(suggestion: SlashSuggestion) => {

‎src/components/CommandPalette.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useCommandRegistry } from "@/contexts/CommandRegistryContext";
55
import type { CommandAction } from "@/contexts/CommandRegistryContext";
66
import { formatKeybind, KEYBINDS, isEditableElement, matchesKeybind } from "@/utils/ui/keybinds";
77
import { getSlashCommandSuggestions } from "@/utils/slashCommands/suggestions";
8+
import { CUSTOM_EVENTS } from "@/constants/events";
89

910
const Overlay = styled.div`
1011
position: fixed;
@@ -254,7 +255,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
254255
shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`,
255256
run: () => {
256257
const text = s.replacement;
257-
window.dispatchEvent(new CustomEvent("cmux:insertToChatInput", { detail: { text } }));
258+
window.dispatchEvent(
259+
new CustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { detail: { text } })
260+
);
258261
},
259262
})),
260263
},

‎src/constants/events.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Custom Event Constants
3+
* These are window-level custom events used for cross-component communication
4+
*/
5+
6+
export const CUSTOM_EVENTS = {
7+
/**
8+
* Event to show a toast notification when thinking level changes
9+
* Detail: { workspaceId: string, level: ThinkingLevel }
10+
*/
11+
THINKING_LEVEL_TOAST: "cmux:thinkingLevelToast",
12+
13+
/**
14+
* Event to insert text into the chat input
15+
* Detail: { text: string }
16+
*/
17+
INSERT_TO_CHAT_INPUT: "cmux:insertToChatInput",
18+
19+
/**
20+
* Event to open the model selector
21+
* No detail
22+
*/
23+
OPEN_MODEL_SELECTOR: "cmux:openModelSelector",
24+
} as const;
25+
26+
/**
27+
* Helper to create a storage change event name for a specific key
28+
* Used by usePersistedState for same-tab synchronization
29+
*/
30+
export const getStorageChangeEvent = (key: string): string => `storage-change:${key}`;

‎src/constants/storage.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* LocalStorage Key Constants and Helpers
3+
* These keys are used for persisting state in localStorage
4+
*/
5+
6+
/**
7+
* Helper to create a thinking level storage key for a workspace
8+
* Format: "thinkingLevel:{workspaceId}"
9+
*/
10+
export const getThinkingLevelKey = (workspaceId: string): string => `thinkingLevel:${workspaceId}`;

‎src/contexts/ThinkingContext.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
22
import React, { createContext, useContext } from "react";
33
import type { ThinkingLevel } from "@/types/thinking";
44
import { usePersistedState } from "@/hooks/usePersistedState";
5+
import { getThinkingLevelKey } from "@/constants/storage";
56

67
interface ThinkingContextType {
78
thinkingLevel: ThinkingLevel;
@@ -16,9 +17,11 @@ interface ThinkingProviderProps {
1617
}
1718

1819
export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({ workspaceId, children }) => {
20+
const key = getThinkingLevelKey(workspaceId);
1921
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(
20-
`thinkingLevel:${workspaceId}`,
21-
"off"
22+
key,
23+
"off",
24+
{ listener: true } // Listen for changes from command palette and other sources
2225
);
2326

2427
return (

‎src/hooks/usePersistedState.ts‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,39 @@
11
import type { Dispatch, SetStateAction } from "react";
22
import { useState, useCallback, useEffect } from "react";
3+
import { getStorageChangeEvent } from "@/constants/events";
34

45
type SetValue<T> = T | ((prev: T) => T);
56

7+
/**
8+
* Update a persisted state value from outside the hook.
9+
* This is useful when you need to update state from a different component/context
10+
* that doesn't have access to the setter (e.g., command palette updating workspace state).
11+
*
12+
* @param key - The same localStorage key used in usePersistedState
13+
* @param value - The new value to set
14+
*/
15+
export function updatePersistedState<T>(key: string, value: T): void {
16+
if (typeof window === "undefined" || !window.localStorage) {
17+
return;
18+
}
19+
20+
try {
21+
if (value === undefined || value === null) {
22+
window.localStorage.removeItem(key);
23+
} else {
24+
window.localStorage.setItem(key, JSON.stringify(value));
25+
}
26+
27+
// Dispatch custom event for same-tab synchronization
28+
const customEvent = new CustomEvent(getStorageChangeEvent(key), {
29+
detail: { key, newValue: value },
30+
});
31+
window.dispatchEvent(customEvent);
32+
} catch (error) {
33+
console.warn(`Error writing to localStorage key "${key}":`, error);
34+
}
35+
}
36+
637
interface UsePersistedStateOptions {
738
/** Enable listening to storage changes from other components/tabs */
839
listener?: boolean;
@@ -85,7 +116,7 @@ export function usePersistedState<T>(
85116
}
86117

87118
// Dispatch custom event for same-tab synchronization
88-
const customEvent = new CustomEvent(`storage-change:${key}`, {
119+
const customEvent = new CustomEvent(getStorageChangeEvent(key), {
89120
detail: { key, newValue },
90121
});
91122
window.dispatchEvent(customEvent);
@@ -137,16 +168,17 @@ export function usePersistedState<T>(
137168
};
138169

139170
// Listen to both storage events (cross-tab) and custom events (same-tab)
171+
const storageChangeEvent = getStorageChangeEvent(key);
140172
window.addEventListener("storage", handleStorageChange);
141-
window.addEventListener(`storage-change:${key}`, handleStorageChange);
173+
window.addEventListener(storageChangeEvent, handleStorageChange);
142174

143175
return () => {
144176
// Cancel pending animation frame
145177
if (rafId !== null) {
146178
cancelAnimationFrame(rafId);
147179
}
148180
window.removeEventListener("storage", handleStorageChange);
149-
window.removeEventListener(`storage-change:${key}`, handleStorageChange);
181+
window.removeEventListener(storageChangeEvent, handleStorageChange);
150182
};
151183
}, [key, options?.listener]);
152184

‎src/utils/commands/sources.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const mk = (over: Partial<Parameters<typeof buildCoreSources>[0]> = {}) => {
2121
workspaceId: "w1",
2222
},
2323
streamingModels: new Map<string, string>(),
24+
getThinkingLevel: () => "off",
25+
onSetThinkingLevel: () => undefined,
2426
onCreateWorkspace: async () => {
2527
await Promise.resolve();
2628
},
@@ -47,3 +49,24 @@ test("buildCoreSources includes create/switch workspace actions", () => {
4749
expect(titles.includes("Open Current Workspace in Terminal")).toBe(true);
4850
expect(titles.includes("Open Workspace in Terminal…")).toBe(true);
4951
});
52+
53+
test("buildCoreSources adds thinking effort command", () => {
54+
const sources = mk({ getThinkingLevel: () => "medium" });
55+
const actions = sources.flatMap((s) => s());
56+
const thinkingAction = actions.find((a) => a.id === "thinking:set-level");
57+
58+
expect(thinkingAction).toBeDefined();
59+
expect(thinkingAction?.subtitle).toContain("Medium");
60+
});
61+
62+
test("thinking effort command submits selected level", async () => {
63+
const onSetThinkingLevel = jest.fn();
64+
const sources = mk({ onSetThinkingLevel, getThinkingLevel: () => "low" });
65+
const actions = sources.flatMap((s) => s());
66+
const thinkingAction = actions.find((a) => a.id === "thinking:set-level");
67+
68+
expect(thinkingAction?.prompt).toBeDefined();
69+
await thinkingAction!.prompt!.onSubmit({ thinkingLevel: "high" });
70+
71+
expect(onSetThinkingLevel).toHaveBeenCalledWith("w1", "high");
72+
});

0 commit comments

Comments
 (0)