커맨드 선택 팝업 (Ctrl+Shift+C): 커서 위치에서 빠른 명령어 전환

- command-popup: Vanilla JS 팝업 (히스토리 팝업과 동일 패턴)
  Arrow↑↓ 선택, Enter 적용, 1-5 직접 선택, ESC 닫기
- WindowManager: showCommandPopup/hideCommandPopup/sendKeyToCommandPopup
- bootstrap: Ctrl+Shift+C 단축키 등록 + command:selected IPC
- electron.vite.config: command-popup HTML 빌드 엔트리 추가
- 선택한 명령어가 즉시 activeInstructionId로 설정 → 다음 녹음에 적용
This commit is contained in:
Yun Chan 2026-04-05 13:26:59 +09:00
parent fea923d302
commit 1361b95a4d
6 changed files with 419 additions and 4 deletions

View file

@ -43,6 +43,10 @@ export default defineConfig({
'popups/history-popup': resolve(
__dirname,
'src/renderer/popups/history-popup/index.html'
),
'popups/command-popup': resolve(
__dirname,
'src/renderer/popups/command-popup/index.html'
)
}
}

View file

@ -3,7 +3,7 @@
import { join } from 'path'
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService } from './services/ConfigService'
import { initConfigService, configGet, configSet } from './services/ConfigService'
import { getHotkeyService } from './services/HotkeyService'
import { getVoiceModeService } from './services/VoiceModeService'
import { getLocalLLMService } from './services/LocalLLMService'
@ -19,7 +19,11 @@ import {
showHistoryPopup,
hideHistoryPopup,
sendKeyToHistoryPopup,
isHistoryPopupVisible
isHistoryPopupVisible,
showCommandPopup,
hideCommandPopup,
sendKeyToCommandPopup,
isCommandPopupVisible,
} from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { registerAllIpcHandlers } from './ipc'
@ -125,6 +129,20 @@ async function initPopupWindows(): Promise<void> {
}
})
// Ctrl+Shift+C → 커맨드 선택 팝업 토글
globalShortcut.register('Ctrl+Shift+C', () => {
if (isCommandPopupVisible()) {
hideCommandPopup()
unregisterPopupNavKeys()
} else {
const instructions = getCustomInstructionService().getAll()
const activeId = configGet('activeInstructionId' as keyof import('@shared/types').AppConfig) as unknown as string
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
registerPopupNavKeys('command')
}
})
setupCommandPopupIPC()
}
// 히스토리 팝업 키 네비게이션 등록/해제
@ -138,12 +156,14 @@ const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' },
]
function registerPopupNavKeys(): void {
function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void {
for (const { accel, key } of POPUP_NAV_KEYS) {
try {
globalShortcut.register(accel, () => {
if (isHistoryPopupVisible()) {
if (target === 'history' && isHistoryPopupVisible()) {
sendKeyToHistoryPopup(key)
} else if (target === 'command' && isCommandPopupVisible()) {
sendKeyToCommandPopup(key)
}
})
} catch {
@ -242,3 +262,24 @@ function setupHistoryPopupIPC(): void {
unregisterPopupNavKeys()
})
}
// ── CommandPopup IPC 연동 ────────────────────────────
function setupCommandPopupIPC(): void {
// 명령어 선택 → 활성 명령어로 설정
ipcMainRef.on('command:selected', (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => {
hideCommandPopup()
unregisterPopupNavKeys()
// ConfigService에 활성 명령어 저장
configSet('activeInstructionId' as keyof import('@shared/types').AppConfig, data.id as never)
configSet('defaultLLMAction', 'custom')
logger.info(`Active command set: ${data.name} (${data.id})`)
})
// 팝업 닫기
ipcMainRef.on('command:dismissed', () => {
hideCommandPopup()
unregisterPopupNavKeys()
})
}

View file

@ -17,6 +17,7 @@ let mainWindow: BrowserWindow | null = null
let recordingTipWindow: BrowserWindow | null = null
let resultPopupWindow: BrowserWindow | null = null
let historyPopupWindow: BrowserWindow | null = null
let commandPopupWindow: BrowserWindow | null = null
// ── 메인 윈도우 ───────────────────────────────────────
@ -355,12 +356,93 @@ export function isHistoryPopupVisible(): boolean {
return !!(historyPopupWindow && !historyPopupWindow.isDestroyed() && historyPopupWindow.isVisible())
}
// ── CommandPopup 팝업 ─────────────────────────────────
function createCommandPopupWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 340,
height: 300,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/command-popup/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/command-popup/index.html'))
}
win.on('closed', () => { commandPopupWindow = null })
return win
}
export function getCommandPopupWindow(): BrowserWindow {
if (!commandPopupWindow || commandPopupWindow.isDestroyed()) {
commandPopupWindow = createCommandPopupWindow()
}
return commandPopupWindow
}
export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void {
const win = getCommandPopupWindow()
const cursorPos = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursorPos)
const popupWidth = 340
const popupHeight = Math.min(80 + commands.length * 48, 400)
let x = cursorPos.x - Math.round(popupWidth / 2)
let y = cursorPos.y - popupHeight - 20
x = Math.max(display.workArea.x, Math.min(x, display.workArea.x + display.workArea.width - popupWidth))
if (y < display.workArea.y) { y = cursorPos.y + 20 }
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
win.webContents.send('command:showItems', { commands, activeId })
if (!win.isVisible()) { win.showInactive() }
win.webContents.send('command:show', {})
}
export function hideCommandPopup(): void {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
commandPopupWindow.webContents.send('command:hide', {})
setTimeout(() => {
if (commandPopupWindow && !commandPopupWindow.isDestroyed()) {
commandPopupWindow.hide()
}
}, 150)
}
}
export function sendKeyToCommandPopup(key: string): void {
if (commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible()) {
commandPopupWindow.webContents.send('command:keyEvent', { key })
}
}
export function isCommandPopupVisible(): boolean {
return !!(commandPopupWindow && !commandPopupWindow.isDestroyed() && commandPopupWindow.isVisible())
}
// ── 프리로딩 ──────────────────────────────────────────
export function preloadPopupWindows(): void {
getRecordingTipWindow()
getResultPopupWindow()
getHistoryPopupWindow()
getCommandPopupWindow()
logger.info('Popup windows preloaded')
}

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./style.css">
<title>Command Popup</title>
</head>
<body>
<div id="root">
<div id="container" class="command-popup">
<div id="title" class="title">COMMAND SELECT</div>
<div id="items" class="items"></div>
<div class="hints">
<span>↑↓ 선택</span>
<span>⏎ 적용</span>
<span>ESC ×</span>
</div>
</div>
</div>
<script src="./script.js"></script>
</body>
</html>

View file

@ -0,0 +1,129 @@
// CommandPopup 팝업 스크립트
// Ctrl+Shift+C로 커서 위치에 명령어 선택 팝업
// Arrow↑↓ 선택, Enter 적용, 1-5 직접 선택, ESC 닫기
;(function () {
'use strict'
var container = document.getElementById('container')
var itemsContainer = document.getElementById('items')
var items = []
var selectedIndex = 0
var currentCommands = []
var currentActiveId = null
// ── 아이템 렌더링 ───────────────────────────────────
function renderItems(commands, activeId) {
currentCommands = commands
currentActiveId = activeId
itemsContainer.innerHTML = ''
items = []
if (commands.length === 0) {
var empty = document.createElement('div')
empty.style.cssText = 'color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 24px 16px;'
empty.textContent = '명령어가 없습니다'
itemsContainer.appendChild(empty)
return
}
commands.forEach(function (cmd, index) {
var div = document.createElement('div')
div.className = 'item' + (index === selectedIndex ? ' selected' : '') + (cmd.id === activeId ? ' active' : '')
div.dataset.index = String(index)
var num = document.createElement('span')
num.className = 'item-number'
num.textContent = String(index + 1)
var name = document.createElement('span')
name.className = 'item-name'
name.textContent = cmd.name
var desc = document.createElement('span')
desc.className = 'item-desc'
desc.textContent = cmd.description
div.appendChild(num)
div.appendChild(name)
if (cmd.id === activeId) {
var badge = document.createElement('span')
badge.className = 'item-active-badge'
badge.textContent = '●'
div.appendChild(badge)
}
div.appendChild(desc)
itemsContainer.appendChild(div)
items.push(div)
div.addEventListener('click', function () {
selectAndApply(index)
})
})
}
// ── 선택 업데이트 ───────────────────────────────────
function updateSelection(newIndex) {
if (currentCommands.length === 0) return
if (newIndex < 0) newIndex = currentCommands.length - 1
if (newIndex >= currentCommands.length) newIndex = 0
items.forEach(function (item, i) {
if (i === newIndex) {
item.classList.add('selected')
} else {
item.classList.remove('selected')
}
})
selectedIndex = newIndex
if (items[selectedIndex]) {
items[selectedIndex].scrollIntoView({ block: 'nearest' })
}
}
// ── 선택 적용 ───────────────────────────────────────
function selectAndApply(index) {
if (index < 0 || index >= currentCommands.length) return
var cmd = currentCommands[index]
window.popupAPI.send('command:selected', { id: cmd.id, name: cmd.name })
}
// ── IPC 리스너 ───────────────────────────────────────
window.popupAPI.on('command:showItems', function (data) {
selectedIndex = 0
renderItems(data.commands || [], data.activeId || null)
container.classList.remove('hiding')
})
window.popupAPI.on('command:show', function () {
container.classList.add('visible')
container.classList.remove('hiding')
})
window.popupAPI.on('command:hide', function () {
container.classList.add('hiding')
container.classList.remove('visible')
})
window.popupAPI.on('command:keyEvent', function (data) {
var key = data.key
if (key === 'ArrowUp') {
updateSelection(selectedIndex - 1)
} else if (key === 'ArrowDown') {
updateSelection(selectedIndex + 1)
} else if (key === 'Enter') {
selectAndApply(selectedIndex)
} else if (key === 'Escape') {
window.popupAPI.send('command:dismissed', {})
} else if (key >= '1' && key <= '9') {
var idx = parseInt(key, 10) - 1
if (idx < currentCommands.length) {
selectAndApply(idx)
}
}
})
})()

View file

@ -0,0 +1,136 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
-webkit-app-region: no-drag;
user-select: none;
}
#root {
width: 100%;
height: 100%;
}
.command-popup {
background: #242427; /* d3roPalette.bg.card */
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
opacity: 0;
transform: translateY(4px) scale(0.98);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
min-width: 280px;
max-width: 380px;
}
.command-popup.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.command-popup.hiding {
opacity: 0;
transform: translateY(-4px) scale(0.98);
transition-duration: 0.1s;
transition-timing-function: ease-in;
}
.title {
color: #5c2615; /* d3roPalette.text.dimLabel */
font-size: 9px;
font-weight: 700;
letter-spacing: 1.5px;
padding: 6px 10px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.items {
max-height: 320px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
padding: 10px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 100ms;
gap: 10px;
position: relative;
}
.item:hover,
.item.selected {
background: rgba(255, 255, 255, 0.06);
}
.item.selected::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
background: #f25b29; /* d3roPalette.accent.amber */
border-radius: 1.5px;
}
.item.active {
background: rgba(242, 91, 41, 0.08);
}
.item-number {
color: rgba(255, 255, 255, 0.3);
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
min-width: 16px;
text-align: center;
}
.item-name {
flex: 1;
color: rgba(255, 255, 255, 0.87);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-desc {
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
white-space: nowrap;
flex-shrink: 0;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.item-active-badge {
color: #f25b29;
font-size: 10px;
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.hints {
display: flex;
justify-content: center;
gap: 16px;
padding: 6px 0 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.hints span {
color: rgba(255, 255, 255, 0.25);
font-size: 10px;
}