diff --git a/src/main/index.ts b/src/main/index.ts index 10472226..aaa63a85 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences } from 'electron' import { join } from 'path' -import { existsSync, readdirSync, statSync, createReadStream } from 'fs' +import { existsSync, readdirSync, statSync, createReadStream, readFileSync, writeFileSync } from 'fs' import { createInterface } from 'readline' import { homedir } from 'os' import { ControlPlane } from './claude/control-plane' @@ -8,8 +8,9 @@ import { ensureSkills, type SkillStatus } from './skills/installer' import { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog' import { log as _log, LOG_FILE, flushLogs } from './logger' import { getCliEnv } from './cli-env' -import { IPC } from '../shared/types' -import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types' +import { DEFAULT_SHORTCUT_SETTINGS, IPC } from '../shared/types' +import type { RunOptions, NormalizedEvent, EnrichedError, ShortcutSettings } from '../shared/types' +import { loadShortcutSettings, registerShortcutSettings, saveShortcutSettings } from './shortcut-settings' const DEBUG_MODE = process.env.CLUI_DEBUG === '1' const SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1' @@ -22,6 +23,7 @@ let mainWindow: BrowserWindow | null = null let tray: Tray | null = null let screenshotCounter = 0 let toggleSequence = 0 +let currentShortcutSettings: ShortcutSettings = DEFAULT_SHORTCUT_SETTINGS // Feature flag: enable PTY interactive permissions transport const INTERACTIVE_PTY = process.env.CLUI_INTERACTIVE_PERMISSIONS_PTY === '1' @@ -864,6 +866,38 @@ ipcMain.handle(IPC.GET_THEME, () => { return { isDark: nativeTheme.shouldUseDarkColors } }) +ipcMain.handle(IPC.GET_SHORTCUT_SETTINGS, () => currentShortcutSettings) + +ipcMain.handle(IPC.SET_SHORTCUT_SETTINGS, (_event, settings: ShortcutSettings) => { + const previousSettings = currentShortcutSettings + + try { + const registration = registerShortcutSettings(settings, toggleWindow) + if (!registration.ok) { + registerShortcutSettings(previousSettings, toggleWindow) + return { + ok: false, + error: registration.error, + settings: previousSettings, + } + } + + currentShortcutSettings = registration.settings + saveShortcutSettings(currentShortcutSettings) + return { + ok: true, + settings: currentShortcutSettings, + } + } catch (err) { + registerShortcutSettings(previousSettings, toggleWindow) + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + settings: previousSettings, + } + } +}) + nativeTheme.on('updated', () => { broadcast(IPC.THEME_CHANGED, nativeTheme.shouldUseDarkColors) }) @@ -940,13 +974,19 @@ app.whenReady().then(async () => { } - // Primary: Option+Space (2 keys, doesn't conflict with shell) - // Fallback: Cmd+Shift+K kept as secondary shortcut - const registered = globalShortcut.register('Alt+Space', () => toggleWindow('shortcut Alt+Space')) - if (!registered) { - log('Alt+Space shortcut registration failed — macOS input sources may claim it') + currentShortcutSettings = loadShortcutSettings() + let shortcutRegistration = registerShortcutSettings(currentShortcutSettings, toggleWindow) + if (!shortcutRegistration.ok) { + log(`Shortcut registration failed for saved settings — ${shortcutRegistration.error}`) + currentShortcutSettings = DEFAULT_SHORTCUT_SETTINGS + shortcutRegistration = registerShortcutSettings(currentShortcutSettings, toggleWindow) + if (shortcutRegistration.ok) { + saveShortcutSettings(currentShortcutSettings) + } + } + if (!shortcutRegistration.ok) { + log(`Default shortcut registration failed — ${shortcutRegistration.error}`) } - globalShortcut.register('CommandOrControl+Shift+K', () => toggleWindow('shortcut Cmd/Ctrl+Shift+K')) const trayIconPath = join(__dirname, '../../resources/trayTemplate.png') const trayIcon = nativeImage.createFromPath(trayIconPath) diff --git a/src/main/shortcut-settings.ts b/src/main/shortcut-settings.ts new file mode 100644 index 00000000..39b00b09 --- /dev/null +++ b/src/main/shortcut-settings.ts @@ -0,0 +1,185 @@ +import { app, globalShortcut } from 'electron' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { DEFAULT_SHORTCUT_SETTINGS, type ShortcutSettings } from '../shared/types' + +const MODIFIER_ALIASES: Record = { + alt: 'Alt', + option: 'Alt', + opt: 'Alt', + '⌥': 'Alt', + shift: 'Shift', + '⇧': 'Shift', + ctrl: 'Control', + control: 'Control', + '^': 'Control', + cmd: 'CommandOrControl', + command: 'CommandOrControl', + commandorcontrol: 'CommandOrControl', + cmdorctrl: 'CommandOrControl', + cmdctrl: 'CommandOrControl', + '⌘': 'CommandOrControl', + super: 'Super', + meta: 'Super', + win: 'Super', + windows: 'Super', +} + +const KEY_ALIASES: Record = { + space: 'Space', + spacebar: 'Space', + enter: 'Enter', + return: 'Enter', + esc: 'Escape', + escape: 'Escape', + tab: 'Tab', + backspace: 'Backspace', + delete: 'Delete', + del: 'Delete', + insert: 'Insert', + ins: 'Insert', + home: 'Home', + end: 'End', + pageup: 'PageUp', + pagedown: 'PageDown', + pgup: 'PageUp', + pgdn: 'PageDown', + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', +} + +function getShortcutSettingsPath(): string { + return join(app.getPath('userData'), 'shortcut-settings.json') +} + +function normalizeShortcut(input: string): string { + const rawParts = input + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + + if (rawParts.length === 0) return '' + + const modifiers = new Set() + let key = '' + + for (const rawPart of rawParts) { + const lower = rawPart.toLowerCase() + const modifier = MODIFIER_ALIASES[lower] + if (modifier) { + modifiers.add(modifier) + continue + } + + if (key) { + throw new Error('Only one non-modifier key is allowed per shortcut.') + } + + if (/^f([1-9]|1\d|2[0-4])$/i.test(rawPart)) { + key = rawPart.toUpperCase() + continue + } + + if (/^[a-z]$/i.test(rawPart)) { + key = rawPart.toUpperCase() + continue + } + + if (/^\d$/.test(rawPart)) { + key = rawPart + continue + } + + const aliasKey = KEY_ALIASES[lower] + if (aliasKey) { + key = aliasKey + continue + } + + throw new Error(`Unsupported key token: "${rawPart}"`) + } + + if (!key) { + throw new Error('A shortcut must include one non-modifier key.') + } + + const orderedModifiers = ['CommandOrControl', 'Control', 'Alt', 'Shift', 'Super'] + .filter((modifier) => modifiers.has(modifier)) + + return [...orderedModifiers, key].join('+') +} + +function normalizeShortcutSettings(settings: ShortcutSettings): ShortcutSettings { + const primaryShortcut = settings.primaryShortcut?.trim() + ? normalizeShortcut(settings.primaryShortcut) + : null + const secondaryShortcut = settings.secondaryShortcut?.trim() + ? normalizeShortcut(settings.secondaryShortcut) + : null + + if (primaryShortcut && secondaryShortcut && secondaryShortcut === primaryShortcut) { + throw new Error('Primary and secondary shortcuts must be different.') + } + + return { primaryShortcut, secondaryShortcut } +} + +export function loadShortcutSettings(): ShortcutSettings { + try { + const filePath = getShortcutSettingsPath() + if (!existsSync(filePath)) return DEFAULT_SHORTCUT_SETTINGS + + const raw = JSON.parse(readFileSync(filePath, 'utf-8')) as Partial + return normalizeShortcutSettings({ + primaryShortcut: raw.primaryShortcut === undefined + ? DEFAULT_SHORTCUT_SETTINGS.primaryShortcut + : raw.primaryShortcut, + secondaryShortcut: raw.secondaryShortcut === undefined + ? DEFAULT_SHORTCUT_SETTINGS.secondaryShortcut + : raw.secondaryShortcut, + }) + } catch { + return DEFAULT_SHORTCUT_SETTINGS + } +} + +export function saveShortcutSettings(settings: ShortcutSettings): void { + const filePath = getShortcutSettingsPath() + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify(settings, null, 2) + '\n', 'utf-8') +} + +export function registerShortcutSettings( + settings: ShortcutSettings, + onToggle: (source: string) => void, +): { ok: boolean; error?: string; settings: ShortcutSettings } { + const normalized = normalizeShortcutSettings(settings) + globalShortcut.unregisterAll() + + if ( + normalized.primaryShortcut && + !globalShortcut.register(normalized.primaryShortcut, () => onToggle(`shortcut ${normalized.primaryShortcut}`)) + ) { + return { + ok: false, + error: `Could not register primary shortcut "${normalized.primaryShortcut}". It may be invalid or already in use.`, + settings: normalized, + } + } + + if ( + normalized.secondaryShortcut && + !globalShortcut.register(normalized.secondaryShortcut, () => onToggle(`shortcut ${normalized.secondaryShortcut}`)) + ) { + globalShortcut.unregisterAll() + return { + ok: false, + error: `Could not register secondary shortcut "${normalized.secondaryShortcut}". It may be invalid or already in use.`, + settings: normalized, + } + } + + return { ok: true, settings: normalized } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 81344d61..2b16755c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,17 @@ import { contextBridge, ipcRenderer } from 'electron' import { IPC } from '../shared/types' -import type { RunOptions, NormalizedEvent, HealthReport, EnrichedError, Attachment, SessionMeta, CatalogPlugin, SessionLoadMessage } from '../shared/types' +import type { + RunOptions, + NormalizedEvent, + HealthReport, + EnrichedError, + Attachment, + SessionMeta, + CatalogPlugin, + SessionLoadMessage, + ShortcutSettings, + ShortcutSettingsUpdateResult, +} from '../shared/types' export interface CluiAPI { // ─── Request-response (renderer → main) ─── @@ -33,6 +44,8 @@ export interface CluiAPI { setPermissionMode(mode: string): void getTheme(): Promise<{ isDark: boolean }> onThemeChange(callback: (isDark: boolean) => void): () => void + getShortcutSettings(): Promise + setShortcutSettings(settings: ShortcutSettings): Promise // ─── Window management ─── resizeHeight(height: number): void @@ -84,6 +97,8 @@ const api: CluiAPI = { ipcRenderer.invoke(IPC.MARKETPLACE_UNINSTALL, { pluginName }), setPermissionMode: (mode) => ipcRenderer.send(IPC.SET_PERMISSION_MODE, mode), getTheme: () => ipcRenderer.invoke(IPC.GET_THEME), + getShortcutSettings: () => ipcRenderer.invoke(IPC.GET_SHORTCUT_SETTINGS), + setShortcutSettings: (settings) => ipcRenderer.invoke(IPC.SET_SHORTCUT_SETTINGS, settings), onThemeChange: (callback) => { const handler = (_e: Electron.IpcRendererEvent, isDark: boolean) => callback(isDark) ipcRenderer.on(IPC.THEME_CHANGED, handler) diff --git a/src/renderer/components/SettingsPopover.tsx b/src/renderer/components/SettingsPopover.tsx index f184fb0a..0fd28067 100644 --- a/src/renderer/components/SettingsPopover.tsx +++ b/src/renderer/components/SettingsPopover.tsx @@ -1,11 +1,45 @@ import React, { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { motion } from 'framer-motion' -import { DotsThree, Bell, ArrowsOutSimple, Moon } from '@phosphor-icons/react' +import { DotsThree, Bell, ArrowsOutSimple, Moon, Keyboard, Target, X } from '@phosphor-icons/react' import { useThemeStore } from '../theme' import { useSessionStore } from '../stores/sessionStore' import { usePopoverLayer } from './PopoverLayer' import { useColors } from '../theme' +import { DEFAULT_SHORTCUT_SETTINGS } from '../../shared/types' + +const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta']) + +function eventToShortcut(e: React.KeyboardEvent): { value: string | null; preview: string } { + const modifiers: string[] = [] + if (e.metaKey) modifiers.push('CommandOrControl') + if (e.ctrlKey) modifiers.push('Control') + if (e.altKey) modifiers.push('Alt') + if (e.shiftKey) modifiers.push('Shift') + + const key = e.key + const preview = [...modifiers, ...(!MODIFIER_KEYS.has(key) ? [normalizeKey(key)] : [])].join('+') + + if (MODIFIER_KEYS.has(key)) { + return { value: null, preview: modifiers.join('+') } + } + + return { + value: [...modifiers, normalizeKey(key)].join('+'), + preview, + } +} + +function normalizeKey(key: string): string { + if (key === ' ') return 'Space' + if (key.length === 1) return key.toUpperCase() + if (key === 'ArrowUp') return 'Up' + if (key === 'ArrowDown') return 'Down' + if (key === 'ArrowLeft') return 'Left' + if (key === 'ArrowRight') return 'Right' + if (key === 'Escape') return 'Escape' + return key +} function RowToggle({ checked, @@ -51,10 +85,20 @@ export function SettingsPopover() { const expandedUI = useThemeStore((s) => s.expandedUI) const setExpandedUI = useThemeStore((s) => s.setExpandedUI) const isExpanded = useSessionStore((s) => s.isExpanded) + const shortcutSettings = useSessionStore((s) => s.shortcutSettings) + const shortcutSettingsSaving = useSessionStore((s) => s.shortcutSettingsSaving) + const shortcutSettingsError = useSessionStore((s) => s.shortcutSettingsError) + const loadShortcutSettings = useSessionStore((s) => s.loadShortcutSettings) + const saveShortcutSettings = useSessionStore((s) => s.saveShortcutSettings) const popoverLayer = usePopoverLayer() const colors = useColors() const [open, setOpen] = useState(false) + const [primaryShortcut, setPrimaryShortcut] = useState(DEFAULT_SHORTCUT_SETTINGS.primaryShortcut || '') + const [secondaryShortcut, setSecondaryShortcut] = useState(DEFAULT_SHORTCUT_SETTINGS.secondaryShortcut || '') + const [saveMessage, setSaveMessage] = useState(null) + const [recordingTarget, setRecordingTarget] = useState<'primary' | 'secondary' | null>(null) + const [recordingPreview, setRecordingPreview] = useState('') const triggerRef = useRef(null) const popoverRef = useRef(null) const [pos, setPos] = useState<{ right: number; top?: number; bottom?: number; maxHeight?: number }>({ right: 0 }) @@ -120,11 +164,147 @@ export function SettingsPopover() { } }, [open, expandedUI, isExpanded, updatePos]) + useEffect(() => { + if (!open || shortcutSettings) return + void loadShortcutSettings() + }, [open, shortcutSettings, loadShortcutSettings]) + + useEffect(() => { + if (!shortcutSettings) return + setPrimaryShortcut(shortcutSettings.primaryShortcut || '') + setSecondaryShortcut(shortcutSettings.secondaryShortcut || '') + }, [shortcutSettings]) + const handleToggle = () => { if (!open) updatePos() setOpen((o) => !o) } + const handleSaveShortcuts = async () => { + setSaveMessage(null) + const ok = await saveShortcutSettings({ + primaryShortcut: primaryShortcut.trim() || null, + secondaryShortcut: secondaryShortcut.trim() || null, + }) + if (ok) setSaveMessage('Shortcut updated') + } + + const stopRecording = () => { + setRecordingTarget(null) + setRecordingPreview('') + } + + const handleRecorderKeyDown = (target: 'primary' | 'secondary') => (e: React.KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + setSaveMessage(null) + + if (e.key === 'Escape') { + stopRecording() + return + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + if (target === 'primary') setPrimaryShortcut('') + else setSecondaryShortcut('') + stopRecording() + return + } + + const { value, preview } = eventToShortcut(e) + setRecordingPreview(preview) + if (!value) return + + if (target === 'primary') setPrimaryShortcut(value) + else setSecondaryShortcut(value) + stopRecording() + } + + const renderShortcutRecorder = ( + label: string, + value: string, + target: 'primary' | 'secondary', + placeholder: string, + ) => { + const isRecording = recordingTarget === target + + return ( +
+ +
+ { + if (target === 'primary') setPrimaryShortcut(e.target.value) + else setSecondaryShortcut(e.target.value) + setSaveMessage(null) + }} + placeholder={isRecording ? 'Press shortcut...' : placeholder} + className="flex-1 px-2 py-1.5 rounded-md text-[11px] outline-none min-w-0" + style={{ + background: colors.surfaceSecondary, + color: value ? colors.textPrimary : colors.textMuted, + border: `1px solid ${isRecording ? colors.accent : colors.containerBorder}`, + }} + /> + + +
+ {isRecording && recordingPreview && ( +
+ {recordingPreview} +
+ )} +
+ ) + } + return ( <> + + + + +
+ {/* Theme */}
diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index 3bd31893..39854bc2 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -1,5 +1,15 @@ import { create } from 'zustand' -import type { TabStatus, NormalizedEvent, EnrichedError, Message, TabState, Attachment, CatalogPlugin, PluginStatus } from '../../shared/types' +import type { + TabStatus, + NormalizedEvent, + EnrichedError, + Message, + TabState, + Attachment, + CatalogPlugin, + PluginStatus, + ShortcutSettings, +} from '../../shared/types' import { useThemeStore } from '../theme' import notificationSrc from '../../../resources/notification.mp3' @@ -32,6 +42,9 @@ interface State { preferredModel: string | null /** Global permission mode: 'ask' shows cards, 'auto' auto-approves all tool calls */ permissionMode: 'ask' | 'auto' + shortcutSettings: ShortcutSettings | null + shortcutSettingsSaving: boolean + shortcutSettingsError: string | null // Marketplace state marketplaceOpen: boolean @@ -45,6 +58,8 @@ interface State { // Actions initStaticInfo: () => Promise + loadShortcutSettings: () => Promise + saveShortcutSettings: (settings: ShortcutSettings) => Promise setPreferredModel: (model: string | null) => void setPermissionMode: (mode: 'ask' | 'auto') => void createTab: () => Promise @@ -128,6 +143,9 @@ export const useSessionStore = create((set, get) => ({ staticInfo: null, preferredModel: null, permissionMode: 'ask', + shortcutSettings: null, + shortcutSettingsSaving: false, + shortcutSettingsError: null, // Marketplace marketplaceOpen: false, @@ -154,6 +172,34 @@ export const useSessionStore = create((set, get) => ({ } catch {} }, + loadShortcutSettings: async () => { + try { + const settings = await window.clui.getShortcutSettings() + set({ shortcutSettings: settings, shortcutSettingsError: null }) + } catch (err) { + set({ shortcutSettingsError: err instanceof Error ? err.message : String(err) }) + } + }, + + saveShortcutSettings: async (settings) => { + set({ shortcutSettingsSaving: true, shortcutSettingsError: null }) + try { + const result = await window.clui.setShortcutSettings(settings) + set({ + shortcutSettingsSaving: false, + shortcutSettingsError: result.ok ? null : (result.error || 'Failed to update shortcuts'), + shortcutSettings: result.settings, + }) + return result.ok + } catch (err) { + set({ + shortcutSettingsSaving: false, + shortcutSettingsError: err instanceof Error ? err.message : String(err), + }) + return false + } + }, + setPreferredModel: (model) => { set({ preferredModel: model }) }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 9745613e..1bb4dedf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -294,6 +294,24 @@ export interface CatalogPlugin { isSkillMd: boolean // true = individual SKILL.md (direct install), false = CLI plugin (bundle install) } +// ─── App Settings ─── + +export interface ShortcutSettings { + primaryShortcut: string | null + secondaryShortcut: string | null +} + +export interface ShortcutSettingsUpdateResult { + ok: boolean + settings: ShortcutSettings + error?: string +} + +export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = { + primaryShortcut: 'Alt+Space', + secondaryShortcut: 'CommandOrControl+Shift+K', +} + // ─── IPC Channel Names ─── export const IPC = { @@ -349,6 +367,10 @@ export const IPC = { GET_THEME: 'clui:get-theme', THEME_CHANGED: 'clui:theme-changed', + // Shortcut settings + GET_SHORTCUT_SETTINGS: 'clui:get-shortcut-settings', + SET_SHORTCUT_SETTINGS: 'clui:set-shortcut-settings', + // Marketplace MARKETPLACE_FETCH: 'clui:marketplace-fetch', MARKETPLACE_INSTALLED: 'clui:marketplace-installed',