@wong2kim/wmux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +6 -0
- package/dist/cli/cli/client.js +102 -0
- package/dist/cli/cli/commands/browser.js +137 -0
- package/dist/cli/cli/commands/input.js +80 -0
- package/dist/cli/cli/commands/notify.js +28 -0
- package/dist/cli/cli/commands/pane.js +88 -0
- package/dist/cli/cli/commands/surface.js +98 -0
- package/dist/cli/cli/commands/system.js +98 -0
- package/dist/cli/cli/commands/workspace.js +117 -0
- package/dist/cli/cli/index.js +140 -0
- package/dist/cli/cli/utils.js +47 -0
- package/dist/cli/shared/constants.js +54 -0
- package/dist/cli/shared/rpc.js +33 -0
- package/dist/cli/shared/types.js +79 -0
- package/dist/mcp/mcp/index.js +60 -0
- package/dist/mcp/mcp/wmux-client.js +146 -0
- package/dist/mcp/shared/constants.js +54 -0
- package/dist/mcp/shared/rpc.js +33 -0
- package/dist/mcp/shared/types.js +79 -0
- package/forge.config.ts +61 -0
- package/index.html +12 -0
- package/package.json +84 -0
- package/postcss.config.js +6 -0
- package/src/cli/client.ts +76 -0
- package/src/cli/commands/browser.ts +128 -0
- package/src/cli/commands/input.ts +72 -0
- package/src/cli/commands/notify.ts +29 -0
- package/src/cli/commands/pane.ts +90 -0
- package/src/cli/commands/surface.ts +102 -0
- package/src/cli/commands/system.ts +95 -0
- package/src/cli/commands/workspace.ts +116 -0
- package/src/cli/index.ts +145 -0
- package/src/cli/utils.ts +44 -0
- package/src/main/index.ts +86 -0
- package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
- package/src/main/ipc/handlers/metadata.handler.ts +56 -0
- package/src/main/ipc/handlers/pty.handler.ts +69 -0
- package/src/main/ipc/handlers/session.handler.ts +17 -0
- package/src/main/ipc/handlers/shell.handler.ts +11 -0
- package/src/main/ipc/registerHandlers.ts +31 -0
- package/src/main/mcp/McpRegistrar.ts +156 -0
- package/src/main/metadata/MetadataCollector.ts +58 -0
- package/src/main/notification/ToastManager.ts +32 -0
- package/src/main/pipe/PipeServer.ts +190 -0
- package/src/main/pipe/RpcRouter.ts +46 -0
- package/src/main/pipe/handlers/_bridge.ts +40 -0
- package/src/main/pipe/handlers/browser.rpc.ts +132 -0
- package/src/main/pipe/handlers/input.rpc.ts +120 -0
- package/src/main/pipe/handlers/meta.rpc.ts +59 -0
- package/src/main/pipe/handlers/notify.rpc.ts +53 -0
- package/src/main/pipe/handlers/pane.rpc.ts +39 -0
- package/src/main/pipe/handlers/surface.rpc.ts +43 -0
- package/src/main/pipe/handlers/system.rpc.ts +36 -0
- package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
- package/src/main/pty/AgentDetector.ts +247 -0
- package/src/main/pty/OscParser.ts +81 -0
- package/src/main/pty/PTYBridge.ts +88 -0
- package/src/main/pty/PTYManager.ts +104 -0
- package/src/main/pty/ShellDetector.ts +63 -0
- package/src/main/session/SessionManager.ts +53 -0
- package/src/main/updater/AutoUpdater.ts +132 -0
- package/src/main/window/createWindow.ts +71 -0
- package/src/mcp/README.md +56 -0
- package/src/mcp/index.ts +153 -0
- package/src/mcp/wmux-client.ts +127 -0
- package/src/preload/index.ts +111 -0
- package/src/preload/preload.ts +108 -0
- package/src/renderer/App.tsx +5 -0
- package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
- package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
- package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
- package/src/renderer/components/Company/CompanyView.tsx +7 -0
- package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
- package/src/renderer/components/Layout/AppLayout.tsx +234 -0
- package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
- package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
- package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
- package/src/renderer/components/Pane/Pane.tsx +122 -0
- package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
- package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
- package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
- package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
- package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
- package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
- package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
- package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
- package/src/renderer/components/Terminal/Terminal.tsx +102 -0
- package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
- package/src/renderer/hooks/useKeyboard.ts +310 -0
- package/src/renderer/hooks/useNotificationListener.ts +80 -0
- package/src/renderer/hooks/useNotificationSound.ts +75 -0
- package/src/renderer/hooks/useRpcBridge.ts +451 -0
- package/src/renderer/hooks/useT.ts +11 -0
- package/src/renderer/hooks/useTerminal.ts +349 -0
- package/src/renderer/hooks/useViCopyMode.ts +320 -0
- package/src/renderer/i18n/index.ts +69 -0
- package/src/renderer/i18n/locales/en.ts +157 -0
- package/src/renderer/i18n/locales/ja.ts +155 -0
- package/src/renderer/i18n/locales/ko.ts +155 -0
- package/src/renderer/i18n/locales/zh.ts +155 -0
- package/src/renderer/index.tsx +6 -0
- package/src/renderer/stores/index.ts +19 -0
- package/src/renderer/stores/slices/notificationSlice.ts +56 -0
- package/src/renderer/stores/slices/paneSlice.ts +141 -0
- package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
- package/src/renderer/stores/slices/uiSlice.ts +247 -0
- package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
- package/src/renderer/styles/globals.css +150 -0
- package/src/renderer/themes.ts +99 -0
- package/src/shared/constants.ts +53 -0
- package/src/shared/electron.d.ts +11 -0
- package/src/shared/rpc.ts +71 -0
- package/src/shared/types.ts +176 -0
- package/tailwind.config.js +11 -0
- package/tsconfig.cli.json +24 -0
- package/tsconfig.json +21 -0
- package/tsconfig.mcp.json +25 -0
- package/vite.main.config.ts +14 -0
- package/vite.preload.config.ts +9 -0
- package/vite.renderer.config.ts +6 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useRef, useEffect, useState } from 'react';
|
|
2
|
+
import { useTerminal } from '../../hooks/useTerminal';
|
|
3
|
+
import { useStore } from '../../stores';
|
|
4
|
+
import ViCopyMode from './ViCopyMode';
|
|
5
|
+
import SearchBar from './SearchBar';
|
|
6
|
+
import '@xterm/xterm/css/xterm.css';
|
|
7
|
+
|
|
8
|
+
interface TerminalProps {
|
|
9
|
+
ptyId?: string;
|
|
10
|
+
shell?: string;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
onPtyCreated?: (ptyId: string) => void;
|
|
13
|
+
/** True when this surface tab is the selected tab inside its pane. */
|
|
14
|
+
isActive?: boolean;
|
|
15
|
+
/** True when the parent workspace is the currently visible workspace.
|
|
16
|
+
* False when the workspace is hidden via display:none in AppLayout.
|
|
17
|
+
* Defaults to true so callers that don't use the all-workspaces rendering
|
|
18
|
+
* pattern continue to work without changes. */
|
|
19
|
+
isWorkspaceVisible?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function TerminalComponent({ ptyId: externalPtyId, shell, cwd, onPtyCreated, isActive = true, isWorkspaceVisible = true }: TerminalProps) {
|
|
23
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const [ptyId, setPtyId] = useState<string | null>(externalPtyId || null);
|
|
25
|
+
const creatingRef = useRef(false);
|
|
26
|
+
|
|
27
|
+
const viCopyModeActive = useStore((s) => s.viCopyModeActive);
|
|
28
|
+
const setViCopyModeActive = useStore((s) => s.setViCopyModeActive);
|
|
29
|
+
const searchBarVisible = useStore((s) => s.searchBarVisible);
|
|
30
|
+
const setSearchBarVisible = useStore((s) => s.setSearchBarVisible);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (externalPtyId) {
|
|
34
|
+
setPtyId(externalPtyId);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (creatingRef.current) return;
|
|
39
|
+
creatingRef.current = true;
|
|
40
|
+
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
window.electronAPI.pty.create({ shell, cwd }).then((result: { id: string }) => {
|
|
43
|
+
if (cancelled) {
|
|
44
|
+
// 이미 unmount됨 — PTY 정리
|
|
45
|
+
window.electronAPI.pty.dispose(result.id);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setPtyId(result.id);
|
|
49
|
+
onPtyCreated?.(result.id);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return () => { cancelled = true; };
|
|
53
|
+
}, [externalPtyId, shell, cwd]); // onPtyCreated 제거 (stale closure 방지)
|
|
54
|
+
|
|
55
|
+
// isVisible = workspace is shown AND this surface tab is the active one.
|
|
56
|
+
// useTerminal uses this to skip fit() when the container is display:none.
|
|
57
|
+
const isVisible = isWorkspaceVisible && isActive;
|
|
58
|
+
const { terminal: terminalRef, findNext, findPrevious, clearSearch } = useTerminal(containerRef, { ptyId, isVisible });
|
|
59
|
+
|
|
60
|
+
const showViCopyMode = viCopyModeActive && isActive && terminalRef.current !== null;
|
|
61
|
+
const showSearchBar = searchBarVisible && isActive;
|
|
62
|
+
|
|
63
|
+
const handleCloseSearch = () => {
|
|
64
|
+
clearSearch();
|
|
65
|
+
setSearchBarVisible(false);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
style={{
|
|
71
|
+
display: isActive ? 'flex' : 'none',
|
|
72
|
+
flexDirection: 'column',
|
|
73
|
+
width: '100%',
|
|
74
|
+
height: '100%',
|
|
75
|
+
position: 'relative',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{/* xterm mount point */}
|
|
79
|
+
<div
|
|
80
|
+
ref={containerRef}
|
|
81
|
+
style={{ width: '100%', height: '100%', padding: '4px' }}
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{/* Search bar overlay */}
|
|
85
|
+
{showSearchBar && (
|
|
86
|
+
<SearchBar
|
|
87
|
+
onFindNext={findNext}
|
|
88
|
+
onFindPrevious={findPrevious}
|
|
89
|
+
onClose={handleCloseSearch}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{/* Vi Copy Mode overlay — rendered inside the relative wrapper */}
|
|
94
|
+
{showViCopyMode && terminalRef.current && (
|
|
95
|
+
<ViCopyMode
|
|
96
|
+
terminal={terminalRef.current}
|
|
97
|
+
onExit={() => setViCopyModeActive(false)}
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect, useCallback } from 'react';
|
|
2
|
+
import type { Terminal } from '@xterm/xterm';
|
|
3
|
+
import { useViCopyMode } from '../../hooks/useViCopyMode';
|
|
4
|
+
import { useT } from '../../hooks/useT';
|
|
5
|
+
|
|
6
|
+
interface ViCopyModeProps {
|
|
7
|
+
terminal: Terminal;
|
|
8
|
+
onExit: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function ViCopyMode({ terminal, onExit }: ViCopyModeProps) {
|
|
12
|
+
const t = useT();
|
|
13
|
+
const {
|
|
14
|
+
cursorRow,
|
|
15
|
+
cursorCol,
|
|
16
|
+
isVisual,
|
|
17
|
+
handleKey,
|
|
18
|
+
exit,
|
|
19
|
+
copySelection,
|
|
20
|
+
} = useViCopyMode({ terminal, isActive: true }, onExit);
|
|
21
|
+
|
|
22
|
+
const onKeyDown = useCallback(
|
|
23
|
+
(e: KeyboardEvent) => {
|
|
24
|
+
// Let the overlay consume all keystrokes so they don't reach the PTY
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
e.stopPropagation();
|
|
27
|
+
|
|
28
|
+
const { key, ctrlKey } = e;
|
|
29
|
+
|
|
30
|
+
// y with visual selection triggers copy
|
|
31
|
+
if (!ctrlKey && key === 'y') {
|
|
32
|
+
copySelection();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
handleKey(key, ctrlKey);
|
|
37
|
+
},
|
|
38
|
+
[handleKey, copySelection],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
window.addEventListener('keydown', onKeyDown, true); // capture phase
|
|
43
|
+
return () => window.removeEventListener('keydown', onKeyDown, true);
|
|
44
|
+
}, [onKeyDown]);
|
|
45
|
+
|
|
46
|
+
// Calculate cursor pixel position relative to the xterm viewport.
|
|
47
|
+
// xterm renders each cell at roughly (fontSize * charWidth) dimensions.
|
|
48
|
+
// We use CSS variables / approximate values here.
|
|
49
|
+
const cellWidth = 8.4; // approximate character width in px at 14px font size
|
|
50
|
+
const cellHeight = 17; // approximate line height in px at 14px font size
|
|
51
|
+
|
|
52
|
+
// The row offset relative to the current viewport
|
|
53
|
+
const viewportY = terminal.buffer.active.viewportY;
|
|
54
|
+
const relativeRow = cursorRow - viewportY;
|
|
55
|
+
|
|
56
|
+
const cursorStyle: React.CSSProperties = {
|
|
57
|
+
position: 'absolute',
|
|
58
|
+
left: `calc(${cursorCol} * ${cellWidth}px + 4px)`, // 4px matches container padding
|
|
59
|
+
top: `calc(${relativeRow} * ${cellHeight}px + 4px)`,
|
|
60
|
+
width: `${cellWidth}px`,
|
|
61
|
+
height: `${cellHeight}px`,
|
|
62
|
+
backgroundColor: 'rgba(var(--accent-yellow-rgb),0.7)', // catppuccin yellow, semi-transparent
|
|
63
|
+
mixBlendMode: 'screen',
|
|
64
|
+
pointerEvents: 'none',
|
|
65
|
+
zIndex: 10,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
{/* Invisible full-size overlay that blocks mouse interaction with terminal */}
|
|
71
|
+
<div
|
|
72
|
+
className="absolute inset-0"
|
|
73
|
+
style={{ zIndex: 9, cursor: 'text' }}
|
|
74
|
+
onClick={() => exit()}
|
|
75
|
+
/>
|
|
76
|
+
|
|
77
|
+
{/* Cursor highlight block */}
|
|
78
|
+
<div style={cursorStyle} aria-hidden="true" />
|
|
79
|
+
|
|
80
|
+
{/* Status bar */}
|
|
81
|
+
<div
|
|
82
|
+
className="absolute bottom-0 left-0 right-0 flex items-center justify-between px-3 py-0.5 text-xs font-mono select-none"
|
|
83
|
+
style={{
|
|
84
|
+
zIndex: 20,
|
|
85
|
+
backgroundColor: 'rgba(var(--bg-base-rgb),0.92)',
|
|
86
|
+
borderTop: '1px solid rgba(var(--accent-blue-rgb),0.4)',
|
|
87
|
+
color: 'var(--accent-blue)', // catppuccin blue
|
|
88
|
+
backdropFilter: 'blur(4px)',
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<span className="font-semibold tracking-widest">
|
|
92
|
+
{isVisual ? t('viCopy.visual') : t('viCopy.mode')}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="text-[var(--text-subtle)]">
|
|
95
|
+
{cursorRow}:{cursorCol}
|
|
96
|
+
|
|
97
|
+
<span className="text-[var(--text-sub2)]">
|
|
98
|
+
h/j/k/l w/b v y ESC
|
|
99
|
+
</span>
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useStore } from '../stores';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert a KeyboardEvent into a normalized key combo string.
|
|
6
|
+
* e.g. Ctrl+Shift held, key='1' → 'Ctrl+Shift+1'
|
|
7
|
+
* no modifiers, key='F7' → 'F7'
|
|
8
|
+
*/
|
|
9
|
+
function formatKeyCombo(ctrl: boolean, shift: boolean, alt: boolean, key: string): string {
|
|
10
|
+
const parts: string[] = [];
|
|
11
|
+
if (ctrl) parts.push('Ctrl');
|
|
12
|
+
if (shift) parts.push('Shift');
|
|
13
|
+
if (alt) parts.push('Alt');
|
|
14
|
+
let normalizedKey = key;
|
|
15
|
+
if (key.length === 1) normalizedKey = key.toUpperCase();
|
|
16
|
+
parts.push(normalizedKey);
|
|
17
|
+
return parts.join('+');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useKeyboard() {
|
|
21
|
+
const store = useStore;
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const handler = (e: KeyboardEvent) => {
|
|
25
|
+
const ctrl = e.ctrlKey;
|
|
26
|
+
const shift = e.shiftKey;
|
|
27
|
+
const alt = e.altKey;
|
|
28
|
+
const key = e.key;
|
|
29
|
+
|
|
30
|
+
// Skip shortcuts when typing in input/textarea/contenteditable
|
|
31
|
+
// Exception: function keys (F1-F12) and custom keybindings should always work
|
|
32
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
33
|
+
const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable;
|
|
34
|
+
const isFunctionKey = key.length > 1 && /^F\d{1,2}$/.test(key);
|
|
35
|
+
if (isEditable && !ctrl && !alt && !isFunctionKey) return;
|
|
36
|
+
|
|
37
|
+
// Ctrl+B: Toggle sidebar
|
|
38
|
+
if (ctrl && !shift && !alt && key === 'b') {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
store.getState().toggleSidebar();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Ctrl+N: New workspace
|
|
45
|
+
if (ctrl && !shift && !alt && key === 'n') {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
store.getState().addWorkspace();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Ctrl+Shift+W: Close workspace
|
|
52
|
+
if (ctrl && shift && !alt && key === 'W') {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
const state = store.getState();
|
|
55
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
56
|
+
if (ws) {
|
|
57
|
+
// 워크스페이스 내 모든 PTY 정리
|
|
58
|
+
const disposePtys = (pane: import('../../shared/types').Pane) => {
|
|
59
|
+
if (pane.type === 'leaf') {
|
|
60
|
+
for (const s of pane.surfaces) {
|
|
61
|
+
if (s.ptyId) window.electronAPI.pty.dispose(s.ptyId);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
for (const child of pane.children) disposePtys(child);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
disposePtys(ws.rootPane);
|
|
68
|
+
}
|
|
69
|
+
state.removeWorkspace(state.activeWorkspaceId);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Ctrl+1~9: Switch workspace
|
|
74
|
+
if (ctrl && !shift && !alt && key >= '1' && key <= '9') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
const { workspaces } = store.getState();
|
|
77
|
+
const idx = key === '9' ? workspaces.length - 1 : parseInt(key) - 1;
|
|
78
|
+
if (idx >= 0 && idx < workspaces.length) {
|
|
79
|
+
store.getState().setActiveWorkspace(workspaces[idx].id);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Ctrl+D: Split right (horizontal)
|
|
85
|
+
if (ctrl && !shift && !alt && key === 'd') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
const state = store.getState();
|
|
88
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
89
|
+
if (ws) {
|
|
90
|
+
state.splitPane(ws.activePaneId, 'horizontal');
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Ctrl+Shift+D: Split down (vertical)
|
|
96
|
+
if (ctrl && shift && !alt && key === 'D') {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
const state = store.getState();
|
|
99
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
100
|
+
if (ws) {
|
|
101
|
+
state.splitPane(ws.activePaneId, 'vertical');
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Ctrl+T: New surface
|
|
107
|
+
if (ctrl && !shift && !alt && key === 't') {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
const state = store.getState();
|
|
110
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
111
|
+
if (ws) {
|
|
112
|
+
window.electronAPI.pty.create().then((result: { id: string }) => {
|
|
113
|
+
store.getState().addSurface(ws.activePaneId, result.id, 'Terminal', '');
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Ctrl+W: Close surface
|
|
120
|
+
if (ctrl && !shift && !alt && key === 'w') {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
const state = store.getState();
|
|
123
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
124
|
+
if (!ws) return;
|
|
125
|
+
const findLeaf = (pane: import('../../shared/types').Pane): import('../../shared/types').PaneLeaf | null => {
|
|
126
|
+
if (pane.type === 'leaf' && pane.id === ws.activePaneId) return pane;
|
|
127
|
+
if (pane.type === 'branch') {
|
|
128
|
+
for (const c of pane.children) {
|
|
129
|
+
const found = findLeaf(c);
|
|
130
|
+
if (found) return found;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
const activePane = findLeaf(ws.rootPane);
|
|
136
|
+
if (activePane && activePane.activeSurfaceId) {
|
|
137
|
+
const surface = activePane.surfaces.find((s) => s.id === activePane.activeSurfaceId);
|
|
138
|
+
if (surface?.ptyId) {
|
|
139
|
+
window.electronAPI.pty.dispose(surface.ptyId);
|
|
140
|
+
}
|
|
141
|
+
state.closeSurface(activePane.id, activePane.activeSurfaceId);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Ctrl+Shift+]: Next surface
|
|
147
|
+
if (ctrl && shift && !alt && key === ']') {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
const state = store.getState();
|
|
150
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
151
|
+
if (ws) state.nextSurface(ws.activePaneId);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ctrl+Shift+[: Previous surface
|
|
156
|
+
if (ctrl && shift && !alt && key === '[') {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
const state = store.getState();
|
|
159
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
160
|
+
if (ws) state.prevSurface(ws.activePaneId);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Alt+Ctrl+Arrow: Focus pane directionally
|
|
165
|
+
if (ctrl && alt && !shift && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
const dirMap: Record<string, 'up' | 'down' | 'left' | 'right'> = {
|
|
168
|
+
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
|
169
|
+
};
|
|
170
|
+
store.getState().focusPaneDirection(dirMap[key]);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Ctrl+I: Toggle notification panel
|
|
175
|
+
if (ctrl && !shift && !alt && key === 'i') {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
store.getState().toggleNotificationPanel();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Ctrl+Shift+M: Toggle message feed panel
|
|
182
|
+
if (ctrl && shift && !alt && key === 'm') {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
store.getState().toggleMessageFeed();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Ctrl+K: Toggle command palette
|
|
189
|
+
if (ctrl && !shift && !alt && key === 'k') {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
store.getState().toggleCommandPalette();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Ctrl+,: Toggle settings panel
|
|
196
|
+
if (ctrl && !shift && !alt && key === ',') {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
store.getState().toggleSettingsPanel();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Ctrl+Shift+U: Jump to latest unread notification's workspace
|
|
203
|
+
if (ctrl && shift && !alt && key === 'U') {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
const state = store.getState();
|
|
206
|
+
const unread = state.notifications
|
|
207
|
+
.filter((n) => !n.read)
|
|
208
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
209
|
+
if (unread.length > 0) {
|
|
210
|
+
const latest = unread[0];
|
|
211
|
+
state.setActiveWorkspace(latest.workspaceId);
|
|
212
|
+
state.markRead(latest.id);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Ctrl+Shift+R: Rename workspace (triggers inline rename in sidebar)
|
|
218
|
+
// This is handled by the Sidebar component via a custom event
|
|
219
|
+
if (ctrl && shift && !alt && key === 'R') {
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
document.dispatchEvent(new CustomEvent('wmux:rename-workspace'));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Ctrl+Shift+L: Open browser panel in a new horizontal split
|
|
226
|
+
if (ctrl && shift && !alt && key === 'L') {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
const state = store.getState();
|
|
229
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
230
|
+
if (ws) {
|
|
231
|
+
state.splitPane(ws.activePaneId, 'horizontal');
|
|
232
|
+
// After split, the new pane becomes active; add browser surface to it
|
|
233
|
+
const newState = store.getState();
|
|
234
|
+
const newWs = newState.workspaces.find((w) => w.id === newState.activeWorkspaceId);
|
|
235
|
+
if (newWs) {
|
|
236
|
+
newState.addBrowserSurface(newWs.activePaneId);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Ctrl+Shift+X: Enter Vi Copy Mode for terminal scrollback
|
|
243
|
+
// (Ctrl+Shift+C is reserved for clipboard copy)
|
|
244
|
+
if (ctrl && shift && !alt && key === 'X') {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
store.getState().setViCopyModeActive(true);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Ctrl+F: Toggle terminal search bar
|
|
251
|
+
if (ctrl && !shift && !alt && key === 'f') {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
store.getState().toggleSearchBar();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Ctrl+Shift+H: Flash active pane to highlight its position
|
|
258
|
+
if (ctrl && shift && !alt && key === 'H') {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
document.dispatchEvent(new CustomEvent('wmux:flash-pane'));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Ctrl+Shift+O: Toggle Company View overlay
|
|
265
|
+
if (ctrl && shift && !alt && key === 'O') {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
store.getState().toggleCompanyView();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── Custom keybindings → terminal input ─────────────────────────
|
|
272
|
+
const { customKeybindings } = store.getState();
|
|
273
|
+
if (customKeybindings.length > 0) {
|
|
274
|
+
const pressed = formatKeyCombo(ctrl, shift, alt, key);
|
|
275
|
+
const match = customKeybindings.find((kb) => kb.key === pressed);
|
|
276
|
+
if (match) {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
e.stopImmediatePropagation();
|
|
279
|
+
const state = store.getState();
|
|
280
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
281
|
+
if (ws) {
|
|
282
|
+
const findLeaf = (pane: import('../../shared/types').Pane): import('../../shared/types').PaneLeaf | null => {
|
|
283
|
+
if (pane.type === 'leaf' && pane.id === ws.activePaneId) return pane;
|
|
284
|
+
if (pane.type === 'branch') {
|
|
285
|
+
for (const c of pane.children) {
|
|
286
|
+
const found = findLeaf(c);
|
|
287
|
+
if (found) return found;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
};
|
|
292
|
+
const leaf = findLeaf(ws.rootPane);
|
|
293
|
+
if (leaf) {
|
|
294
|
+
const surface = leaf.surfaces.find((s) => s.id === leaf.activeSurfaceId);
|
|
295
|
+
if (surface?.ptyId) {
|
|
296
|
+
const text = match.sendEnter ? match.command + '\r' : match.command;
|
|
297
|
+
window.electronAPI.pty.write(surface.ptyId, text);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Use capture phase so we run BEFORE xterm's stopPropagation
|
|
307
|
+
window.addEventListener('keydown', handler, true);
|
|
308
|
+
return () => window.removeEventListener('keydown', handler, true);
|
|
309
|
+
}, []);
|
|
310
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useStore } from '../stores';
|
|
3
|
+
import type { NotificationType, Pane, PaneLeaf } from '../../shared/types';
|
|
4
|
+
import { playNotificationSound } from './useNotificationSound';
|
|
5
|
+
|
|
6
|
+
function findSurfaceByPtyId(root: Pane, ptyId: string): { surfaceId: string; paneId: string } | null {
|
|
7
|
+
if (root.type === 'leaf') {
|
|
8
|
+
const surface = root.surfaces.find((s) => s.ptyId === ptyId);
|
|
9
|
+
if (surface) return { surfaceId: surface.id, paneId: root.id };
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
for (const child of root.children) {
|
|
13
|
+
const found = findSurfaceByPtyId(child, ptyId);
|
|
14
|
+
if (found) return found;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Throttle notification sounds — min 2s between sounds of same type
|
|
20
|
+
const lastSoundTime: Record<string, number> = {};
|
|
21
|
+
const SOUND_THROTTLE_MS = 2000;
|
|
22
|
+
|
|
23
|
+
export function useNotificationListener() {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const unsubNotif = window.electronAPI.notification.onNew((ptyId, data) => {
|
|
26
|
+
const state = useStore.getState();
|
|
27
|
+
// Find which workspace/surface this ptyId belongs to
|
|
28
|
+
for (const ws of state.workspaces) {
|
|
29
|
+
const found = findSurfaceByPtyId(ws.rootPane, ptyId);
|
|
30
|
+
if (found) {
|
|
31
|
+
state.addNotification({
|
|
32
|
+
surfaceId: found.surfaceId,
|
|
33
|
+
workspaceId: ws.id,
|
|
34
|
+
type: data.type as NotificationType,
|
|
35
|
+
title: data.title,
|
|
36
|
+
body: data.body,
|
|
37
|
+
});
|
|
38
|
+
// Play sound if enabled (throttled)
|
|
39
|
+
if (useStore.getState().notificationSoundEnabled) {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const key = data.type;
|
|
42
|
+
if (!lastSoundTime[key] || now - lastSoundTime[key] > SOUND_THROTTLE_MS) {
|
|
43
|
+
lastSoundTime[key] = now;
|
|
44
|
+
playNotificationSound(data.type as NotificationType);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const unsubCwd = window.electronAPI.notification.onCwdChanged((ptyId, cwd) => {
|
|
53
|
+
const state = useStore.getState();
|
|
54
|
+
for (const ws of state.workspaces) {
|
|
55
|
+
const found = findSurfaceByPtyId(ws.rootPane, ptyId);
|
|
56
|
+
if (found) {
|
|
57
|
+
state.updateWorkspaceMetadata(ws.id, { cwd });
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const unsubMeta = window.electronAPI.metadata.onUpdate((ptyId, data) => {
|
|
64
|
+
const state = useStore.getState();
|
|
65
|
+
for (const ws of state.workspaces) {
|
|
66
|
+
const found = findSurfaceByPtyId(ws.rootPane, ptyId);
|
|
67
|
+
if (found) {
|
|
68
|
+
state.updateWorkspaceMetadata(ws.id, data);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
unsubNotif();
|
|
76
|
+
unsubCwd();
|
|
77
|
+
unsubMeta();
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNotificationSound
|
|
3
|
+
*
|
|
4
|
+
* Web Audio API를 사용해 외부 파일 없이 짧은 비프음을 생성합니다.
|
|
5
|
+
* notificationSoundEnabled 설정이 true일 때만 재생됩니다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let audioCtx: AudioContext | null = null;
|
|
9
|
+
|
|
10
|
+
function getAudioContext(): AudioContext {
|
|
11
|
+
if (!audioCtx) {
|
|
12
|
+
audioCtx = new AudioContext();
|
|
13
|
+
}
|
|
14
|
+
return audioCtx;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 알림 타입별 소리 재생.
|
|
19
|
+
* - agent: 두 음 상승 (성공 신호)
|
|
20
|
+
* - error: 낮은 단음 (경고)
|
|
21
|
+
* - warning: 중간 단음
|
|
22
|
+
* - info: 기본 단음
|
|
23
|
+
*/
|
|
24
|
+
export function playNotificationSound(type: 'agent' | 'error' | 'warning' | 'info' = 'info'): void {
|
|
25
|
+
try {
|
|
26
|
+
const ctx = getAudioContext();
|
|
27
|
+
if (ctx.state === 'suspended') {
|
|
28
|
+
ctx.resume().catch(() => undefined);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const now = ctx.currentTime;
|
|
32
|
+
|
|
33
|
+
const configs: Array<{ freq: number; time: number; duration: number }> = [];
|
|
34
|
+
|
|
35
|
+
switch (type) {
|
|
36
|
+
case 'agent':
|
|
37
|
+
// 두 음 상승: 솔 → 도
|
|
38
|
+
configs.push({ freq: 784, time: now, duration: 0.1 });
|
|
39
|
+
configs.push({ freq: 1047, time: now + 0.12, duration: 0.12 });
|
|
40
|
+
break;
|
|
41
|
+
case 'error':
|
|
42
|
+
// 낮은 단음
|
|
43
|
+
configs.push({ freq: 330, time: now, duration: 0.18 });
|
|
44
|
+
break;
|
|
45
|
+
case 'warning':
|
|
46
|
+
// 중간 단음
|
|
47
|
+
configs.push({ freq: 523, time: now, duration: 0.14 });
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
// info: 짧은 고음
|
|
51
|
+
configs.push({ freq: 880, time: now, duration: 0.1 });
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const { freq, time, duration } of configs) {
|
|
56
|
+
const osc = ctx.createOscillator();
|
|
57
|
+
const gain = ctx.createGain();
|
|
58
|
+
|
|
59
|
+
osc.type = 'sine';
|
|
60
|
+
osc.frequency.setValueAtTime(freq, time);
|
|
61
|
+
|
|
62
|
+
gain.gain.setValueAtTime(0, time);
|
|
63
|
+
gain.gain.linearRampToValueAtTime(0.18, time + 0.01);
|
|
64
|
+
gain.gain.linearRampToValueAtTime(0, time + duration);
|
|
65
|
+
|
|
66
|
+
osc.connect(gain);
|
|
67
|
+
gain.connect(ctx.destination);
|
|
68
|
+
|
|
69
|
+
osc.start(time);
|
|
70
|
+
osc.stop(time + duration + 0.01);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// AudioContext가 지원되지 않는 환경에서는 무시
|
|
74
|
+
}
|
|
75
|
+
}
|