@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.
Files changed (122) hide show
  1. package/README.md +157 -0
  2. package/assets/icon.ico +0 -0
  3. package/assets/icon.svg +6 -0
  4. package/dist/cli/cli/client.js +102 -0
  5. package/dist/cli/cli/commands/browser.js +137 -0
  6. package/dist/cli/cli/commands/input.js +80 -0
  7. package/dist/cli/cli/commands/notify.js +28 -0
  8. package/dist/cli/cli/commands/pane.js +88 -0
  9. package/dist/cli/cli/commands/surface.js +98 -0
  10. package/dist/cli/cli/commands/system.js +98 -0
  11. package/dist/cli/cli/commands/workspace.js +117 -0
  12. package/dist/cli/cli/index.js +140 -0
  13. package/dist/cli/cli/utils.js +47 -0
  14. package/dist/cli/shared/constants.js +54 -0
  15. package/dist/cli/shared/rpc.js +33 -0
  16. package/dist/cli/shared/types.js +79 -0
  17. package/dist/mcp/mcp/index.js +60 -0
  18. package/dist/mcp/mcp/wmux-client.js +146 -0
  19. package/dist/mcp/shared/constants.js +54 -0
  20. package/dist/mcp/shared/rpc.js +33 -0
  21. package/dist/mcp/shared/types.js +79 -0
  22. package/forge.config.ts +61 -0
  23. package/index.html +12 -0
  24. package/package.json +84 -0
  25. package/postcss.config.js +6 -0
  26. package/src/cli/client.ts +76 -0
  27. package/src/cli/commands/browser.ts +128 -0
  28. package/src/cli/commands/input.ts +72 -0
  29. package/src/cli/commands/notify.ts +29 -0
  30. package/src/cli/commands/pane.ts +90 -0
  31. package/src/cli/commands/surface.ts +102 -0
  32. package/src/cli/commands/system.ts +95 -0
  33. package/src/cli/commands/workspace.ts +116 -0
  34. package/src/cli/index.ts +145 -0
  35. package/src/cli/utils.ts +44 -0
  36. package/src/main/index.ts +86 -0
  37. package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
  38. package/src/main/ipc/handlers/metadata.handler.ts +56 -0
  39. package/src/main/ipc/handlers/pty.handler.ts +69 -0
  40. package/src/main/ipc/handlers/session.handler.ts +17 -0
  41. package/src/main/ipc/handlers/shell.handler.ts +11 -0
  42. package/src/main/ipc/registerHandlers.ts +31 -0
  43. package/src/main/mcp/McpRegistrar.ts +156 -0
  44. package/src/main/metadata/MetadataCollector.ts +58 -0
  45. package/src/main/notification/ToastManager.ts +32 -0
  46. package/src/main/pipe/PipeServer.ts +190 -0
  47. package/src/main/pipe/RpcRouter.ts +46 -0
  48. package/src/main/pipe/handlers/_bridge.ts +40 -0
  49. package/src/main/pipe/handlers/browser.rpc.ts +132 -0
  50. package/src/main/pipe/handlers/input.rpc.ts +120 -0
  51. package/src/main/pipe/handlers/meta.rpc.ts +59 -0
  52. package/src/main/pipe/handlers/notify.rpc.ts +53 -0
  53. package/src/main/pipe/handlers/pane.rpc.ts +39 -0
  54. package/src/main/pipe/handlers/surface.rpc.ts +43 -0
  55. package/src/main/pipe/handlers/system.rpc.ts +36 -0
  56. package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
  57. package/src/main/pty/AgentDetector.ts +247 -0
  58. package/src/main/pty/OscParser.ts +81 -0
  59. package/src/main/pty/PTYBridge.ts +88 -0
  60. package/src/main/pty/PTYManager.ts +104 -0
  61. package/src/main/pty/ShellDetector.ts +63 -0
  62. package/src/main/session/SessionManager.ts +53 -0
  63. package/src/main/updater/AutoUpdater.ts +132 -0
  64. package/src/main/window/createWindow.ts +71 -0
  65. package/src/mcp/README.md +56 -0
  66. package/src/mcp/index.ts +153 -0
  67. package/src/mcp/wmux-client.ts +127 -0
  68. package/src/preload/index.ts +111 -0
  69. package/src/preload/preload.ts +108 -0
  70. package/src/renderer/App.tsx +5 -0
  71. package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
  72. package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
  73. package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
  74. package/src/renderer/components/Company/CompanyView.tsx +7 -0
  75. package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
  76. package/src/renderer/components/Layout/AppLayout.tsx +234 -0
  77. package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
  78. package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
  79. package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
  80. package/src/renderer/components/Pane/Pane.tsx +122 -0
  81. package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
  82. package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
  83. package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
  84. package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
  85. package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
  86. package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
  87. package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
  88. package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
  89. package/src/renderer/components/Terminal/Terminal.tsx +102 -0
  90. package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
  91. package/src/renderer/hooks/useKeyboard.ts +310 -0
  92. package/src/renderer/hooks/useNotificationListener.ts +80 -0
  93. package/src/renderer/hooks/useNotificationSound.ts +75 -0
  94. package/src/renderer/hooks/useRpcBridge.ts +451 -0
  95. package/src/renderer/hooks/useT.ts +11 -0
  96. package/src/renderer/hooks/useTerminal.ts +349 -0
  97. package/src/renderer/hooks/useViCopyMode.ts +320 -0
  98. package/src/renderer/i18n/index.ts +69 -0
  99. package/src/renderer/i18n/locales/en.ts +157 -0
  100. package/src/renderer/i18n/locales/ja.ts +155 -0
  101. package/src/renderer/i18n/locales/ko.ts +155 -0
  102. package/src/renderer/i18n/locales/zh.ts +155 -0
  103. package/src/renderer/index.tsx +6 -0
  104. package/src/renderer/stores/index.ts +19 -0
  105. package/src/renderer/stores/slices/notificationSlice.ts +56 -0
  106. package/src/renderer/stores/slices/paneSlice.ts +141 -0
  107. package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
  108. package/src/renderer/stores/slices/uiSlice.ts +247 -0
  109. package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
  110. package/src/renderer/styles/globals.css +150 -0
  111. package/src/renderer/themes.ts +99 -0
  112. package/src/shared/constants.ts +53 -0
  113. package/src/shared/electron.d.ts +11 -0
  114. package/src/shared/rpc.ts +71 -0
  115. package/src/shared/types.ts +176 -0
  116. package/tailwind.config.js +11 -0
  117. package/tsconfig.cli.json +24 -0
  118. package/tsconfig.json +21 -0
  119. package/tsconfig.mcp.json +25 -0
  120. package/vite.main.config.ts +14 -0
  121. package/vite.preload.config.ts +9 -0
  122. 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
+ &nbsp;&nbsp;
97
+ <span className="text-[var(--text-sub2)]">
98
+ h/j/k/l &nbsp; w/b &nbsp; v &nbsp; y &nbsp; 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
+ }