@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,409 @@
1
+ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
2
+ import { useStore } from '../../stores';
3
+ import PaletteItem, { type PaletteItemData, type PaletteCategory } from './PaletteItem';
4
+ import { useT } from '../../hooks/useT';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // SVG Icons (inline, no external dependency)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function IconSearch() {
11
+ return (
12
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
13
+ <circle cx="6.5" cy="6.5" r="4" stroke="currentColor" strokeWidth="1.4" />
14
+ <line x1="9.85" y1="9.85" x2="13" y2="13" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ function IconWorkspace() {
20
+ return (
21
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
22
+ <rect x="1" y="1" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
23
+ <rect x="8" y="1" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
24
+ <rect x="1" y="8" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
25
+ <rect x="8" y="8" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
26
+ </svg>
27
+ );
28
+ }
29
+
30
+ function IconSurface() {
31
+ return (
32
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
33
+ <rect x="1" y="1" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
34
+ <line x1="4" y1="12" x2="10" y2="12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
35
+ <line x1="7" y1="10" x2="7" y2="12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ function IconCommand() {
41
+ return (
42
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
43
+ <polyline points="3,5 1,7 3,9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
44
+ <polyline points="11,5 13,7 11,9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
45
+ <line x1="8.5" y1="3" x2="5.5" y2="11" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
46
+ </svg>
47
+ );
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Fuzzy match helper
52
+ // Scores a string against a query. Returns null if no match, else a score
53
+ // (higher = better). Consecutive character matches are rewarded.
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function fuzzyScore(str: string, query: string): number | null {
57
+ if (query.length === 0) return 0;
58
+ const s = str.toLowerCase();
59
+ const q = query.toLowerCase();
60
+ let si = 0;
61
+ let qi = 0;
62
+ let score = 0;
63
+ let consecutive = 0;
64
+ let lastMatchIdx = -1;
65
+
66
+ while (si < s.length && qi < q.length) {
67
+ if (s[si] === q[qi]) {
68
+ // Reward consecutive matches and start-of-word matches
69
+ consecutive++;
70
+ if (lastMatchIdx === si - 1) {
71
+ score += 2 + consecutive;
72
+ } else {
73
+ consecutive = 0;
74
+ score += 1;
75
+ }
76
+ // Bonus for matching at word start
77
+ if (si === 0 || s[si - 1] === ' ' || s[si - 1] === '-' || s[si - 1] === '_') {
78
+ score += 3;
79
+ }
80
+ lastMatchIdx = si;
81
+ qi++;
82
+ }
83
+ si++;
84
+ }
85
+
86
+ return qi === q.length ? score : null;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // CommandPalette component
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export default function CommandPalette() {
94
+ const t = useT();
95
+ const visible = useStore((s) => s.commandPaletteVisible);
96
+ const setVisible = useStore((s) => s.setCommandPaletteVisible);
97
+ const workspaces = useStore((s) => s.workspaces);
98
+ const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
99
+
100
+ const [query, setQuery] = useState('');
101
+ const [activeIdx, setActiveIdx] = useState(0);
102
+ const inputRef = useRef<HTMLInputElement>(null);
103
+ const listRef = useRef<HTMLDivElement>(null);
104
+
105
+ // -------------------------------------------------------------------------
106
+ // Build item list
107
+ // -------------------------------------------------------------------------
108
+
109
+ const buildItems = useCallback((): PaletteItemData[] => {
110
+ const items: PaletteItemData[] = [];
111
+
112
+ // Workspaces
113
+ workspaces.forEach((ws) => {
114
+ items.push({
115
+ id: `ws-${ws.id}`,
116
+ label: ws.name,
117
+ category: 'workspace' as PaletteCategory,
118
+ icon: <IconWorkspace />,
119
+ action: () => {
120
+ useStore.getState().setActiveWorkspace(ws.id);
121
+ setVisible(false);
122
+ },
123
+ });
124
+ });
125
+
126
+ // Surfaces — gather from active workspace leaf panes
127
+ const activeWs = workspaces.find((w) => w.id === activeWorkspaceId);
128
+ if (activeWs) {
129
+ const collectSurfaces = (pane: import('../../../shared/types').Pane) => {
130
+ if (pane.type === 'leaf') {
131
+ pane.surfaces.forEach((surface) => {
132
+ items.push({
133
+ id: `surface-${surface.id}`,
134
+ label: surface.title || 'Terminal',
135
+ category: 'surface' as PaletteCategory,
136
+ icon: <IconSurface />,
137
+ action: () => {
138
+ useStore.getState().setActiveSurface(pane.id, surface.id);
139
+ setVisible(false);
140
+ },
141
+ });
142
+ });
143
+ } else if (pane.type === 'branch') {
144
+ pane.children.forEach(collectSurfaces);
145
+ }
146
+ };
147
+ collectSurfaces(activeWs.rootPane);
148
+ }
149
+
150
+ // Built-in commands
151
+ const commands: Array<{ label: string; action: () => void }> = [
152
+ {
153
+ label: t('palette.cmd.toggleSidebar'),
154
+ action: () => { useStore.getState().toggleSidebar(); setVisible(false); },
155
+ },
156
+ {
157
+ label: t('palette.cmd.splitRight'),
158
+ action: () => {
159
+ const state = useStore.getState();
160
+ const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
161
+ if (ws) state.splitPane(ws.activePaneId, 'horizontal');
162
+ setVisible(false);
163
+ },
164
+ },
165
+ {
166
+ label: t('palette.cmd.splitDown'),
167
+ action: () => {
168
+ const state = useStore.getState();
169
+ const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
170
+ if (ws) state.splitPane(ws.activePaneId, 'vertical');
171
+ setVisible(false);
172
+ },
173
+ },
174
+ {
175
+ label: t('palette.cmd.newWorkspace'),
176
+ action: () => { useStore.getState().addWorkspace(); setVisible(false); },
177
+ },
178
+ {
179
+ label: t('palette.cmd.newSurface'),
180
+ action: () => {
181
+ const state = useStore.getState();
182
+ const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
183
+ if (ws) {
184
+ window.electronAPI.pty.create().then((result: { id: string }) => {
185
+ useStore.getState().addSurface(ws.activePaneId, result.id, 'Terminal', '');
186
+ });
187
+ }
188
+ setVisible(false);
189
+ },
190
+ },
191
+ {
192
+ label: t('palette.cmd.showNotifications'),
193
+ action: () => { useStore.getState().setNotificationPanelVisible(true); setVisible(false); },
194
+ },
195
+ {
196
+ label: t('palette.cmd.openBrowser'),
197
+ action: () => {
198
+ const state = useStore.getState();
199
+ const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
200
+ if (ws) {
201
+ state.splitPane(ws.activePaneId, 'horizontal');
202
+ const newState = useStore.getState();
203
+ const newWs = newState.workspaces.find((w) => w.id === newState.activeWorkspaceId);
204
+ if (newWs) {
205
+ newState.addBrowserSurface(newWs.activePaneId);
206
+ }
207
+ }
208
+ setVisible(false);
209
+ },
210
+ },
211
+ ];
212
+
213
+ commands.forEach((cmd, i) => {
214
+ items.push({
215
+ id: `cmd-${i}`,
216
+ label: cmd.label,
217
+ category: 'command' as PaletteCategory,
218
+ icon: <IconCommand />,
219
+ action: cmd.action,
220
+ });
221
+ });
222
+
223
+ return items;
224
+ }, [workspaces, activeWorkspaceId, setVisible]);
225
+
226
+ // -------------------------------------------------------------------------
227
+ // Filtered + scored results — useMemo to cache across renders
228
+ // -------------------------------------------------------------------------
229
+
230
+ const results = useMemo((): PaletteItemData[] => {
231
+ const all = buildItems();
232
+ if (!query.trim()) return all;
233
+
234
+ return all
235
+ .map((item) => ({ item, score: fuzzyScore(item.label, query.trim()) }))
236
+ .filter((x) => x.score !== null)
237
+ .sort((a, b) => (b.score as number) - (a.score as number))
238
+ .map((x) => x.item);
239
+ }, [buildItems, query]);
240
+
241
+ // -------------------------------------------------------------------------
242
+ // Reset state when opened
243
+ // -------------------------------------------------------------------------
244
+
245
+ useEffect(() => {
246
+ if (visible) {
247
+ setQuery('');
248
+ setActiveIdx(0);
249
+ // Defer focus to ensure the DOM has rendered
250
+ requestAnimationFrame(() => {
251
+ inputRef.current?.focus();
252
+ });
253
+ }
254
+ }, [visible]);
255
+
256
+ // -------------------------------------------------------------------------
257
+ // Keep activeIdx in bounds when results change
258
+ // -------------------------------------------------------------------------
259
+
260
+ useEffect(() => {
261
+ setActiveIdx((prev) => Math.min(prev, Math.max(results.length - 1, 0)));
262
+ }, [results.length]);
263
+
264
+ // -------------------------------------------------------------------------
265
+ // Scroll active item into view
266
+ // -------------------------------------------------------------------------
267
+
268
+ useEffect(() => {
269
+ const list = listRef.current;
270
+ if (!list) return;
271
+ const activeEl = list.querySelector<HTMLElement>('[data-active="true"]');
272
+ activeEl?.scrollIntoView({ block: 'nearest' });
273
+ }, [activeIdx]);
274
+
275
+ // -------------------------------------------------------------------------
276
+ // Keyboard navigation inside palette
277
+ // -------------------------------------------------------------------------
278
+
279
+ const handleKeyDown = (e: React.KeyboardEvent) => {
280
+ if (e.key === 'Escape') {
281
+ e.preventDefault();
282
+ setVisible(false);
283
+ return;
284
+ }
285
+ if (e.key === 'ArrowDown') {
286
+ e.preventDefault();
287
+ setActiveIdx((prev) => (prev + 1) % Math.max(results.length, 1));
288
+ return;
289
+ }
290
+ if (e.key === 'ArrowUp') {
291
+ e.preventDefault();
292
+ setActiveIdx((prev) => (prev - 1 + Math.max(results.length, 1)) % Math.max(results.length, 1));
293
+ return;
294
+ }
295
+ if (e.key === 'Enter') {
296
+ e.preventDefault();
297
+ results[activeIdx]?.action();
298
+ return;
299
+ }
300
+ };
301
+
302
+ if (!visible) return null;
303
+
304
+ return (
305
+ // Backdrop
306
+ <div
307
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
308
+ style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
309
+ onMouseDown={(e) => {
310
+ // Close when clicking the backdrop, not the palette itself
311
+ if (e.target === e.currentTarget) setVisible(false);
312
+ }}
313
+ >
314
+ {/* Palette container */}
315
+ <div
316
+ className="w-[480px] max-h-[60vh] flex flex-col rounded-xl overflow-hidden shadow-2xl"
317
+ style={{
318
+ backgroundColor: 'var(--bg-base)',
319
+ border: '1px solid var(--bg-surface)',
320
+ boxShadow: '0 25px 60px rgba(0,0,0,0.7)',
321
+ }}
322
+ onMouseDown={(e) => e.stopPropagation()}
323
+ >
324
+ {/* Search input row */}
325
+ <div
326
+ className="flex items-center gap-2.5 px-4 py-3"
327
+ style={{ borderBottom: '1px solid var(--bg-surface)' }}
328
+ >
329
+ <span className="shrink-0 text-[var(--text-subtle)]">
330
+ <IconSearch />
331
+ </span>
332
+ <input
333
+ ref={inputRef}
334
+ type="text"
335
+ value={query}
336
+ onChange={(e) => {
337
+ setQuery(e.target.value);
338
+ setActiveIdx(0);
339
+ }}
340
+ onKeyDown={handleKeyDown}
341
+ placeholder={t('palette.placeholder')}
342
+ className="flex-1 bg-transparent text-[var(--text-main)] text-sm placeholder-[var(--text-muted)] outline-none"
343
+ spellCheck={false}
344
+ autoComplete="off"
345
+ />
346
+ <kbd
347
+ className="shrink-0 text-xs text-[var(--text-muted)] px-1.5 py-0.5 rounded"
348
+ style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
349
+ >
350
+ ESC
351
+ </kbd>
352
+ </div>
353
+
354
+ {/* Results list */}
355
+ <div ref={listRef} className="overflow-y-auto flex-1">
356
+ {results.length === 0 ? (
357
+ <div className="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
358
+ {t('palette.noResults')} &ldquo;{query}&rdquo;
359
+ </div>
360
+ ) : (
361
+ results.map((item, idx) => (
362
+ <div key={item.id} data-active={idx === activeIdx ? 'true' : undefined}>
363
+ <PaletteItem
364
+ item={item}
365
+ isActive={idx === activeIdx}
366
+ onClick={item.action}
367
+ />
368
+ </div>
369
+ ))
370
+ )}
371
+ </div>
372
+
373
+ {/* Footer hint */}
374
+ <div
375
+ className="flex items-center gap-3 px-4 py-2"
376
+ style={{ borderTop: '1px solid var(--bg-surface)', backgroundColor: 'var(--bg-mantle)' }}
377
+ >
378
+ <span className="text-xs text-[var(--text-muted)]">
379
+ <kbd
380
+ className="px-1 py-0.5 rounded mr-0.5"
381
+ style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
382
+ >
383
+ ↑↓
384
+ </kbd>{' '}
385
+ {t('palette.navigate')}
386
+ </span>
387
+ <span className="text-xs text-[var(--text-muted)]">
388
+ <kbd
389
+ className="px-1 py-0.5 rounded mr-0.5"
390
+ style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
391
+ >
392
+ Enter
393
+ </kbd>{' '}
394
+ {t('palette.select')}
395
+ </span>
396
+ <span className="text-xs text-[var(--text-muted)]">
397
+ <kbd
398
+ className="px-1 py-0.5 rounded mr-0.5"
399
+ style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
400
+ >
401
+ Esc
402
+ </kbd>{' '}
403
+ {t('palette.close')}
404
+ </span>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ );
409
+ }
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import { useT } from '../../hooks/useT';
3
+
4
+ export type PaletteCategory = 'workspace' | 'surface' | 'command';
5
+
6
+ export interface PaletteItemData {
7
+ id: string;
8
+ label: string;
9
+ category: PaletteCategory;
10
+ icon: React.ReactNode;
11
+ action: () => void;
12
+ }
13
+
14
+ interface PaletteItemProps {
15
+ item: PaletteItemData;
16
+ isActive: boolean;
17
+ onClick: () => void;
18
+ }
19
+
20
+ const categoryColor: Record<PaletteCategory, string> = {
21
+ workspace: 'text-[var(--accent-blue)]',
22
+ surface: 'text-[var(--accent-green)]',
23
+ command: 'text-[var(--accent-purple)]',
24
+ };
25
+
26
+ export default function PaletteItem({ item, isActive, onClick }: PaletteItemProps) {
27
+ const t = useT();
28
+
29
+ const categoryLabel: Record<PaletteCategory, string> = {
30
+ workspace: t('palette.catWorkspace'),
31
+ surface: t('palette.catSurface'),
32
+ command: t('palette.catCommand'),
33
+ };
34
+
35
+ return (
36
+ <button
37
+ type="button"
38
+ onClick={onClick}
39
+ className={[
40
+ 'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
41
+ isActive
42
+ ? 'bg-[var(--bg-surface)] text-[var(--text-main)]'
43
+ : 'text-[var(--text-sub)] hover:bg-[#2a2a3d] hover:text-[var(--text-main)]',
44
+ ].join(' ')}
45
+ >
46
+ <span className="shrink-0 w-4 h-4 flex items-center justify-center text-[var(--text-subtle)]">
47
+ {item.icon}
48
+ </span>
49
+ <span className="flex-1 truncate text-sm">{item.label}</span>
50
+ <span className={`shrink-0 text-xs font-medium ${categoryColor[item.category]}`}>
51
+ {categoryLabel[item.category]}
52
+ </span>
53
+ </button>
54
+ );
55
+ }
@@ -0,0 +1,122 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { PaneLeaf } from '../../../shared/types';
3
+ import { useStore } from '../../stores';
4
+ import { useT } from '../../hooks/useT';
5
+ import TerminalComponent from '../Terminal/Terminal';
6
+ import BrowserPanel from '../Browser/BrowserPanel';
7
+ import SurfaceTabs from './SurfaceTabs';
8
+
9
+ interface PaneProps {
10
+ pane: PaneLeaf;
11
+ isActive: boolean;
12
+ isWorkspaceVisible?: boolean;
13
+ }
14
+
15
+ export default function PaneComponent({ pane, isActive, isWorkspaceVisible = true }: PaneProps) {
16
+ const t = useT();
17
+ const [flashing, setFlashing] = useState(false);
18
+ const setActivePane = useStore((s) => s.setActivePane);
19
+ const setActiveSurface = useStore((s) => s.setActiveSurface);
20
+ const addSurface = useStore((s) => s.addSurface);
21
+ const closeSurface = useStore((s) => s.closeSurface);
22
+ const updateSurfacePtyId = useStore((s) => s.updateSurfacePtyId);
23
+ const markRead = useStore((s) => s.markRead);
24
+
25
+ // count만 가져와 불필요한 배열 참조 안정성 문제 방지
26
+ const unreadCount = useStore((s) =>
27
+ s.notifications.filter(
28
+ (n) => !n.read && pane.surfaces.some((surf) => surf.id === n.surfaceId),
29
+ ).length,
30
+ );
31
+ const notificationRingEnabled = useStore((s) => s.notificationRingEnabled);
32
+ const hasUnread = !isActive && unreadCount > 0 && notificationRingEnabled;
33
+
34
+ // Ctrl+Shift+H: flash the active pane
35
+ useEffect(() => {
36
+ if (!isActive) return;
37
+ const handler = () => {
38
+ setFlashing(true);
39
+ setTimeout(() => setFlashing(false), 500);
40
+ };
41
+ document.addEventListener('wmux:flash-pane', handler);
42
+ return () => document.removeEventListener('wmux:flash-pane', handler);
43
+ }, [isActive]);
44
+
45
+ const handleClick = useCallback(() => {
46
+ setActivePane(pane.id);
47
+ // 최신 state에서 직접 읽어 stale closure 방지
48
+ const { notifications } = useStore.getState();
49
+ const surfaceIds = new Set(pane.surfaces.map((s) => s.id));
50
+ for (const n of notifications) {
51
+ if (!n.read && surfaceIds.has(n.surfaceId)) {
52
+ markRead(n.id);
53
+ }
54
+ }
55
+ }, [pane.id, pane.surfaces, setActivePane, markRead]);
56
+
57
+ const handleAddSurface = useCallback(() => {
58
+ window.electronAPI.pty.create().then((result: { id: string }) => {
59
+ addSurface(pane.id, result.id, 'Terminal', '');
60
+ });
61
+ }, [pane.id, addSurface]);
62
+
63
+ const closePane = useStore((s) => s.closePane);
64
+
65
+ const handleCloseSurface = useCallback((surfaceId: string) => {
66
+ const surface = pane.surfaces.find((s) => s.id === surfaceId);
67
+ if (surface?.ptyId) {
68
+ window.electronAPI.pty.dispose(surface.ptyId);
69
+ }
70
+ closeSurface(pane.id, surfaceId);
71
+
72
+ // 마지막 Surface가 닫히면 Pane도 자동 제거
73
+ if (pane.surfaces.length <= 1) {
74
+ closePane(pane.id);
75
+ }
76
+ }, [pane.id, pane.surfaces, closeSurface, closePane]);
77
+
78
+ return (
79
+ <div
80
+ className={`flex flex-col h-full w-full relative ${
81
+ isActive ? 'ring-1 ring-[rgba(var(--accent-blue-rgb),0.5)]' : ''
82
+ } ${hasUnread ? 'notification-ring' : ''} ${flashing ? 'pane-flash' : ''}`}
83
+ onClick={handleClick}
84
+ >
85
+ <SurfaceTabs
86
+ surfaces={pane.surfaces}
87
+ activeSurfaceId={pane.activeSurfaceId}
88
+ onSelect={(surfaceId) => setActiveSurface(pane.id, surfaceId)}
89
+ onClose={handleCloseSurface}
90
+ onAdd={handleAddSurface}
91
+ />
92
+
93
+ <div className="flex-1 relative overflow-hidden">
94
+ {pane.surfaces.map((surface) =>
95
+ surface.surfaceType === 'browser' ? (
96
+ <BrowserPanel
97
+ key={surface.id}
98
+ surfaceId={surface.id}
99
+ initialUrl={surface.browserUrl || 'https://google.com'}
100
+ isActive={surface.id === pane.activeSurfaceId}
101
+ onClose={() => handleCloseSurface(surface.id)}
102
+ />
103
+ ) : (
104
+ <TerminalComponent
105
+ key={surface.id}
106
+ ptyId={surface.ptyId || undefined}
107
+ isActive={surface.id === pane.activeSurfaceId}
108
+ isWorkspaceVisible={isWorkspaceVisible}
109
+ onPtyCreated={(ptyId) => updateSurfacePtyId(pane.id, surface.id, ptyId)}
110
+ />
111
+ )
112
+ )}
113
+
114
+ {pane.surfaces.length === 0 && (
115
+ <div className="flex items-center justify-center h-full text-[var(--text-muted)] text-sm">
116
+ {t('pane.empty')}
117
+ </div>
118
+ )}
119
+ </div>
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,41 @@
1
+ import { Panel, Group, Separator } from 'react-resizable-panels';
2
+ import type { Pane as PaneType } from '../../../shared/types';
3
+ import { useStore } from '../../stores';
4
+ import PaneComponent from './Pane';
5
+
6
+ interface PaneContainerProps {
7
+ pane: PaneType;
8
+ isWorkspaceVisible?: boolean;
9
+ }
10
+
11
+ export default function PaneContainer({ pane, isWorkspaceVisible = true }: PaneContainerProps) {
12
+ const activePaneId = useStore((s) => {
13
+ const ws = s.workspaces.find((w) => w.id === s.activeWorkspaceId);
14
+ return ws?.activePaneId || '';
15
+ });
16
+
17
+ if (pane.type === 'leaf') {
18
+ return <PaneComponent pane={pane} isActive={pane.id === activePaneId} isWorkspaceVisible={isWorkspaceVisible} />;
19
+ }
20
+
21
+ const orientation = pane.direction === 'horizontal' ? 'horizontal' : 'vertical';
22
+
23
+ return (
24
+ <Group orientation={orientation} className="h-full w-full">
25
+ {pane.children.map((child, i) => (
26
+ <div key={child.id} className="contents">
27
+ {i > 0 && (
28
+ <Separator
29
+ className={`${
30
+ orientation === 'horizontal' ? 'w-1' : 'h-1'
31
+ } bg-[var(--bg-surface)] hover:bg-[var(--accent-blue)] transition-colors`}
32
+ />
33
+ )}
34
+ <Panel defaultSize={pane.sizes?.[i] ?? 100 / pane.children.length} minSize={10}>
35
+ <PaneContainer pane={child} isWorkspaceVisible={isWorkspaceVisible} />
36
+ </Panel>
37
+ </div>
38
+ ))}
39
+ </Group>
40
+ );
41
+ }
@@ -0,0 +1,46 @@
1
+ import type { Surface } from '../../../shared/types';
2
+ import { useT } from '../../hooks/useT';
3
+
4
+ interface SurfaceTabsProps {
5
+ surfaces: Surface[];
6
+ activeSurfaceId: string;
7
+ onSelect: (surfaceId: string) => void;
8
+ onClose: (surfaceId: string) => void;
9
+ onAdd: () => void;
10
+ }
11
+
12
+ export default function SurfaceTabs({ surfaces, activeSurfaceId, onSelect, onClose }: SurfaceTabsProps) {
13
+ const t = useT();
14
+ // Hide the tab bar entirely when there is only one surface — no tabs needed.
15
+ // The X close button on a single tab would close the pane itself (handled by Pane.tsx),
16
+ // so it is more intuitive to simply not show the tab strip in the single-tab case.
17
+ if (surfaces.length <= 1) {
18
+ return null;
19
+ }
20
+
21
+ return (
22
+ <div className="flex items-center bg-[var(--bg-mantle)] border-b border-[var(--bg-surface)] h-7 overflow-x-auto">
23
+ {surfaces.map((s) => (
24
+ <div
25
+ key={s.id}
26
+ className={`group flex items-center gap-1 px-3 h-full cursor-pointer text-xs border-r border-[var(--bg-surface)] transition-colors ${
27
+ s.id === activeSurfaceId
28
+ ? 'bg-[var(--bg-base)] text-[var(--text-main)]'
29
+ : 'text-[var(--text-subtle)] hover:text-[var(--text-sub)] hover:bg-[rgba(var(--bg-base-rgb),0.5)]'
30
+ }`}
31
+ onClick={() => onSelect(s.id)}
32
+ >
33
+ <span className="truncate max-w-[120px]">{s.title || t('surface.terminal')}</span>
34
+ {/* X close button — always visible, not just on hover */}
35
+ <button
36
+ className="text-[var(--text-subtle)] hover:text-[var(--accent-red)] transition-colors ml-1 leading-none"
37
+ onClick={(e) => { e.stopPropagation(); onClose(s.id); }}
38
+ title={t('surface.closeTab')}
39
+ >
40
+
41
+ </button>
42
+ </div>
43
+ ))}
44
+ </div>
45
+ );
46
+ }