Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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'
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'
Expand All @@ -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'
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
Expand Down
185 changes: 185 additions & 0 deletions src/main/shortcut-settings.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string> = {
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<string>()
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<ShortcutSettings>
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 }
}
17 changes: 16 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -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) ───
Expand Down Expand Up @@ -33,6 +44,8 @@ export interface CluiAPI {
setPermissionMode(mode: string): void
getTheme(): Promise<{ isDark: boolean }>
onThemeChange(callback: (isDark: boolean) => void): () => void
getShortcutSettings(): Promise<ShortcutSettings>
setShortcutSettings(settings: ShortcutSettings): Promise<ShortcutSettingsUpdateResult>

// ─── Window management ───
resizeHeight(height: number): void
Expand Down Expand Up @@ -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)
Expand Down
Loading