Skip to content

Commit 0cd0f41

Browse files
claudeDragonnZhang
authored andcommitted
Add a keyboard shortcut (⌘⇧E) to open the composer's thinking menu
The composer's thinking-level (reasoning effort) picker could only be opened with the mouse. Add a chat.openThinkingMenu action bound to mod+shift+e that opens it from the keyboard, matching Claude Code Desktop's effort-menu shortcut. The action registry lives at the app shell while the dropdown's open state lives in FreeFormInput, so the shortcut is bridged with a scoped craft:open-composer-menu event, mirroring the existing craft:focus-input pattern (shouldHandleScopedInputEvent) so multi-panel layouts target the focused composer. The action auto-appears in the Command Palette and the Keyboard Shortcuts reference. One new i18n key across all locales. Closes #54
1 parent 1b4038c commit 0cd0f41

14 files changed

Lines changed: 179 additions & 0 deletions

File tree

apps/electron/src/renderer/actions/action-i18n.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const ACTION_LABEL_KEYS: Partial<Record<ActionId, string>> = {
3636
'panel.focusPrev': 'shortcuts.action.focusPrevPanel',
3737
'chat.stopProcessing': 'shortcuts.action.stopProcessing',
3838
'chat.cyclePermissionMode': 'shortcuts.action.cyclePermissionMode',
39+
'chat.openThinkingMenu': 'shortcuts.action.openThinkingMenu',
3940
'chat.nextSearchMatch': 'shortcuts.action.nextSearchMatch',
4041
'chat.prevSearchMatch': 'shortcuts.action.prevSearchMatch',
4142
}

apps/electron/src/renderer/actions/definitions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ export const actions = {
202202
category: 'Chat',
203203
when: '!inputFocus && !menuOpen',
204204
},
205+
'chat.openThinkingMenu': {
206+
id: 'chat.openThinkingMenu',
207+
label: 'Open Thinking Menu',
208+
description: 'Open the thinking (reasoning effort) menu in the chat composer',
209+
defaultHotkey: 'mod+shift+e',
210+
category: 'Chat',
211+
},
205212
'chat.nextSearchMatch': {
206213
id: 'chat.nextSearchMatch',
207214
label: 'Next Search Match',

apps/electron/src/renderer/components/app-shell/AppShell.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ import { hasOpenOverlay } from '@/lib/overlay-detection'
222222
import { getNextPermissionMode } from '@/lib/permission-mode-cycle'
223223
import { clearSourceIconCaches } from '@/lib/icon-cache'
224224
import { dispatchFocusInputEvent } from './input/focus-input-events'
225+
import { dispatchOpenComposerMenuEvent } from './input/composer-menu-events'
225226
import { resolveEffectiveConnectionSlug } from '@config/llm-connections'
226227
import { getWorkspaceDisplayName } from '@/utils/workspace'
227228

@@ -1820,6 +1821,12 @@ function AppShellContent({
18201821
useAction('nav.goBackAlt', goBack)
18211822
useAction('nav.goForwardAlt', goForward)
18221823

1824+
// Open the composer's thinking (reasoning effort) menu (CMD+SHIFT+E).
1825+
// Targets the focused panel's composer via the scoped composer-menu event.
1826+
useAction('chat.openThinkingMenu', () =>
1827+
dispatchOpenComposerMenuEvent({ menu: 'thinking' }),
1828+
)
1829+
18231830
// Search match navigation (CMD+G next, CMD+SHIFT+G prev)
18241831
useAction(
18251832
'chat.nextSearchMatch',

apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ import {
102102
clearPendingFocusForSession,
103103
consumePendingFocusForSession,
104104
} from './focus-input-events';
105+
import {
106+
COMPOSER_MENU_EVENT,
107+
type ComposerMenuEventDetail,
108+
} from './composer-menu-events';
105109
import {
106110
getRecentWorkingDirs,
107111
addRecentWorkingDir,
@@ -1291,6 +1295,33 @@ export function FreeFormInput({
12911295
window.removeEventListener('craft:focus-input', handleFocusInput);
12921296
}, [sessionId, isFocusedPanel, richInputRef]);
12931297

1298+
// Listen for craft:open-composer-menu events (open a toolbar menu from a
1299+
// global keyboard shortcut). Scoped like focus-input so the focused panel's
1300+
// composer responds.
1301+
React.useEffect(() => {
1302+
const handleOpenMenu = (e: Event) => {
1303+
const detail = (e as CustomEvent<ComposerMenuEventDetail>).detail;
1304+
if (
1305+
!shouldHandleScopedInputEvent({
1306+
sessionId,
1307+
isFocusedPanel,
1308+
targetSessionId: detail?.sessionId,
1309+
})
1310+
)
1311+
return;
1312+
1313+
if (detail?.menu === 'thinking') {
1314+
// The thinking picker only renders in the full (non-compact) toolbar.
1315+
if (compactMode || !onThinkingLevelChange) return;
1316+
setThinkingDropdownOpen(true);
1317+
}
1318+
};
1319+
1320+
window.addEventListener(COMPOSER_MENU_EVENT, handleOpenMenu);
1321+
return () =>
1322+
window.removeEventListener(COMPOSER_MENU_EVENT, handleOpenMenu);
1323+
}, [sessionId, isFocusedPanel, compactMode, onThinkingLevelChange]);
1324+
12941325
// Recover queued focus requests after session switch/mount races.
12951326
React.useEffect(() => {
12961327
if (!consumePendingFocusForSession(sessionId)) return;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Cross-component bridge for opening a composer toolbar menu from a global
3+
* keyboard shortcut.
4+
*
5+
* The action registry lives at the app shell (it owns the global hotkey
6+
* listener), while the composer's toolbar dropdowns (thinking / model) own
7+
* their controlled `open` state inside `FreeFormInput`. This event lets the
8+
* shell ask the focused composer to open one of its menus, mirroring the
9+
* scoping model of `focus-input-events.ts` so multi-panel layouts target the
10+
* right composer.
11+
*/
12+
13+
/** Composer toolbar menus that can be opened via a shortcut. */
14+
export type ComposerMenu = 'thinking'
15+
16+
export interface ComposerMenuEventDetail {
17+
/** Restrict to a specific session's composer; omit to target the focused panel. */
18+
sessionId?: string
19+
menu: ComposerMenu
20+
}
21+
22+
export const COMPOSER_MENU_EVENT = 'craft:open-composer-menu'
23+
24+
/**
25+
* Ask a composer to open one of its toolbar menus. With no `sessionId`, the
26+
* focused panel's composer handles it (see `shouldHandleScopedInputEvent`).
27+
*/
28+
export function dispatchOpenComposerMenuEvent(detail: ComposerMenuEventDetail): void {
29+
window.dispatchEvent(
30+
new CustomEvent<ComposerMenuEventDetail>(COMPOSER_MENU_EVENT, { detail }),
31+
)
32+
}

docs/loop/feature-ledger.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ log, not the system of record.
3333

3434
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3535
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
36+
| thinking-menu-shortcut | Keyboard shortcut (⌘⇧E) to open the composer's thinking (effort) menu | Claude Code Desktop effort-menu shortcut (⌘⇧E); explicit follow-up flagged in #44 | frontend-only | in-progress | [#54](https://github.com/modelstudioai/openwork/issues/54) || loop/thinking-menu-shortcut | 2026-07-04 | New `chat.openThinkingMenu` action (`mod+shift+e`) → scoped `craft:open-composer-menu` event → `FreeFormInput` opens its already-controlled `thinkingDropdownOpen`. Mirrors `craft:focus-input`/`shouldHandleScopedInputEvent` for multi-panel safety. Auto-appears in Command Palette + Shortcuts reference. 1 new i18n key across 7 locales. Avoids ⌘⇧I (reserved for DevTools) / ⌘⇧M (permission mode already has Shift+Tab). typecheck zero-delta (11 pre-existing); `bun test` zero-delta (56 identical failures, diffed vs main); i18n parity OK; renderer build ✅; assertion transpiles ✅. **CDP blocked locally** (same egress limit: `libsignal` GitHub dep + Electron binary 403). |
3637
| prompt-history-recall | Recall previously-sent prompts in the composer with Up/Down arrows | Claude Code CLI `↑` history + Codex desktop "recover your previous prompt by pressing the up arrow" | frontend-only | pr-open | [#52](https://github.com/modelstudioai/openwork/issues/52) | [#53](https://github.com/modelstudioai/openwork/pull/53) | loop/composer-prompt-history | 2026-07-04 | New pure module `prompt-history.ts` (push/prev/next/entry-at) + global `localStorage['craft-prompt-history']`. Wired into `FreeFormInput.handleKeyDown`: Up recalls when composer empty/recalling, Down walks newer & restores draft, edit/submit/session-switch exit recall. Zero new i18n keys. `data-testid="composer-input"` added for e2e. **DoD:** typecheck:all +0 vs main (11 pre-existing electron errors only); packages/ui clean; `bun test` failure set byte-identical to main (56 pre-existing, verified by stash-diff) + 20 new passing unit tests; renderer build ✅; assertion transpiles. **CDP could not run locally** (egress 403s the Electron binary download, same as prior loop PRs) — assertion included for CI/reviewer. |
3738
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows "Reduce motion" + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | (loop) | 2026-07-03 | Reconciled from GitHub: PR open, awaiting review. Not re-selectable. |
3839
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude desktop / ChatGPT desktop / Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | (loop) | 2026-07-03 | Reconciled from GitHub: PR open, awaiting review. Not re-selectable. |
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Feature assertion: the keyboard shortcut that opens the composer's thinking
3+
* (reasoning effort) menu.
4+
*
5+
* Mirrors Claude Code Desktop's effort-menu shortcut (⌘⇧E). Drives the real
6+
* built app over CDP:
7+
* composer renders a thinking-level trigger with its menu CLOSED → dispatch
8+
* the Cmd/Ctrl+Shift+E keydown at the window level → the thinking menu OPENS
9+
* (lists all six levels).
10+
*
11+
* Asserting the menu is closed *before* the keypress and open *after* it proves
12+
* the shortcut actually opens the menu — not merely that the menu can render.
13+
*/
14+
15+
import type { Assertion } from '../runner';
16+
17+
const TRIGGER = '[data-testid="thinking-level-trigger"]';
18+
const ITEM = '[data-testid="thinking-level-item"]';
19+
20+
/** The six thinking levels are the single source of truth for the count. */
21+
const EXPECTED_LEVEL_COUNT = 6;
22+
23+
/** Menu items that are actually visible (Radix renders them in a portal). */
24+
const VISIBLE_ITEMS_EXPR = `[...document.querySelectorAll(${JSON.stringify(
25+
ITEM,
26+
)})].filter((el) => el.offsetParent !== null)`;
27+
28+
/**
29+
* Dispatch the effort-menu shortcut at the window level, where the action
30+
* registry's capture-phase keydown listener lives. `mod` resolves to Cmd on
31+
* macOS and Ctrl elsewhere; set both `metaKey` and `ctrlKey` so the same event
32+
* matches on either platform (the registry only checks the platform's mod key).
33+
*/
34+
const PRESS_SHORTCUT_EXPR = `(() => {
35+
window.dispatchEvent(new KeyboardEvent('keydown', {
36+
key: 'e',
37+
code: 'KeyE',
38+
ctrlKey: true,
39+
metaKey: true,
40+
shiftKey: true,
41+
bubbles: true,
42+
cancelable: true,
43+
}));
44+
return true;
45+
})()`;
46+
47+
const assertion: Assertion = {
48+
name: 'Cmd/Ctrl+Shift+E opens the composer thinking-level menu',
49+
async run(app) {
50+
const { session } = app;
51+
52+
// App fully mounted.
53+
await session.waitForFunction(
54+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
55+
{ timeoutMs: 30000, message: 'React UI did not mount' },
56+
);
57+
58+
// Reach the ready AppShell (not onboarding / workspace picker) — the same
59+
// stable, non-localized anchor the other composer assertions wait on.
60+
await session.waitForSelector('[aria-label="Craft menu"]', {
61+
timeoutMs: 30000,
62+
message: 'app did not reach the ready AppShell state',
63+
});
64+
65+
// The composer renders a thinking-level trigger.
66+
await session.waitForSelector(TRIGGER, {
67+
timeoutMs: 20000,
68+
message: 'thinking-level picker trigger did not render in the composer',
69+
});
70+
71+
// Precondition: the menu is closed (no visible items).
72+
if ((await session.evaluate<number>(`${VISIBLE_ITEMS_EXPR}.length`)) !== 0) {
73+
throw new Error('thinking-level menu was already open before pressing the shortcut');
74+
}
75+
76+
// Press the shortcut at the window level.
77+
if (!(await session.evaluate<boolean>(PRESS_SHORTCUT_EXPR))) {
78+
throw new Error('failed to dispatch the Cmd/Ctrl+Shift+E keydown');
79+
}
80+
81+
// The menu opens and lists all six thinking levels — proving the shortcut
82+
// opened it.
83+
await session.waitForFunction(
84+
`${VISIBLE_ITEMS_EXPR}.length === ${EXPECTED_LEVEL_COUNT}`,
85+
{
86+
timeoutMs: 8000,
87+
message: `thinking-level menu did not open (expected ${EXPECTED_LEVEL_COUNT} levels) after pressing Cmd/Ctrl+Shift+E`,
88+
},
89+
);
90+
},
91+
};
92+
93+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,7 @@
11731173
"shortcuts.action.newChatInPanel": "Neuer Chat im Panel",
11741174
"shortcuts.action.newWindow": "Neues Fenster",
11751175
"shortcuts.action.nextSearchMatch": "Nächster Treffer",
1176+
"shortcuts.action.openThinkingMenu": "Denk-Menü öffnen",
11761177
"shortcuts.action.prevSearchMatch": "Vorheriger Treffer",
11771178
"shortcuts.action.quit": "Beenden",
11781179
"shortcuts.action.search": "Suchen",

packages/shared/src/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,7 @@
11731173
"shortcuts.action.newChatInPanel": "New Chat in Panel",
11741174
"shortcuts.action.newWindow": "New Window",
11751175
"shortcuts.action.nextSearchMatch": "Next Search Match",
1176+
"shortcuts.action.openThinkingMenu": "Open Thinking Menu",
11761177
"shortcuts.action.prevSearchMatch": "Previous Search Match",
11771178
"shortcuts.action.quit": "Quit",
11781179
"shortcuts.action.search": "Search",

packages/shared/src/i18n/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,7 @@
11731173
"shortcuts.action.newChatInPanel": "Nuevo chat en panel",
11741174
"shortcuts.action.newWindow": "Nueva ventana",
11751175
"shortcuts.action.nextSearchMatch": "Siguiente coincidencia",
1176+
"shortcuts.action.openThinkingMenu": "Abrir menú de razonamiento",
11761177
"shortcuts.action.prevSearchMatch": "Coincidencia anterior",
11771178
"shortcuts.action.quit": "Salir",
11781179
"shortcuts.action.search": "Buscar",

0 commit comments

Comments
 (0)