@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,886 @@
1
+ import { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { useStore } from '../../stores';
3
+ import { LOCALE_OPTIONS, type Locale } from '../../i18n';
4
+ import { useT } from '../../hooks/useT';
5
+ import { THEME_OPTIONS } from '../../themes';
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ type UpdateStatus = 'idle' | 'checking' | 'up-to-date' | 'available' | 'error';
10
+ type TabId = 'general' | 'appearance' | 'notifications' | 'shortcuts' | 'about';
11
+
12
+ // ─── Icon components ──────────────────────────────────────────────────────────
13
+
14
+ function IconX() {
15
+ return (
16
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
17
+ <line x1="2" y1="2" x2="12" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
18
+ <line x1="12" y1="2" x2="2" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
19
+ </svg>
20
+ );
21
+ }
22
+
23
+ function IconRefresh() {
24
+ return (
25
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
26
+ <path d="M1.5 7a5.5 5.5 0 1 0 1.1-3.3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
27
+ <polyline points="1.5,2 1.5,4.5 4,4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ // ─── Toggle switch ─────────────────────────────────────────────────────────────
33
+
34
+ interface ToggleProps {
35
+ checked: boolean;
36
+ onChange: (checked: boolean) => void;
37
+ label: string;
38
+ }
39
+
40
+ function Toggle({ checked, onChange, label }: ToggleProps) {
41
+ return (
42
+ <button
43
+ role="switch"
44
+ aria-checked={checked}
45
+ aria-label={label}
46
+ onClick={() => onChange(!checked)}
47
+ className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none shrink-0"
48
+ style={{ backgroundColor: checked ? 'var(--accent-blue)' : 'var(--bg-overlay)' }}
49
+ >
50
+ <span
51
+ className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
52
+ style={{ transform: checked ? 'translateX(18px)' : 'translateX(2px)' }}
53
+ />
54
+ </button>
55
+ );
56
+ }
57
+
58
+ // ─── Row layout helper ────────────────────────────────────────────────────────
59
+
60
+ function SettingRow({
61
+ label,
62
+ description,
63
+ children,
64
+ }: {
65
+ label: string;
66
+ description?: string;
67
+ children: React.ReactNode;
68
+ }) {
69
+ return (
70
+ <div
71
+ className="flex items-center justify-between px-3 py-2.5 rounded-lg"
72
+ style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
73
+ >
74
+ <div className="min-w-0 mr-3">
75
+ <p className="text-sm text-[color:var(--text-main)]">{label}</p>
76
+ {description && <p className="text-[11px] text-[color:var(--text-muted)] mt-0.5">{description}</p>}
77
+ </div>
78
+ {children}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ // ─── Select dropdown ──────────────────────────────────────────────────────────
84
+
85
+ function SettingSelect({
86
+ value,
87
+ onChange,
88
+ options,
89
+ label,
90
+ }: {
91
+ value: string;
92
+ onChange: (v: string) => void;
93
+ options: { value: string; label: string }[];
94
+ label: string;
95
+ }) {
96
+ return (
97
+ <select
98
+ aria-label={label}
99
+ value={value}
100
+ onChange={(e) => onChange(e.target.value)}
101
+ className="text-xs rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-[color:var(--accent-blue)] font-mono"
102
+ style={{
103
+ backgroundColor: 'var(--bg-surface)',
104
+ color: 'var(--text-main)',
105
+ border: '1px solid var(--bg-overlay)',
106
+ minWidth: 130,
107
+ }}
108
+ >
109
+ {options.map((o) => (
110
+ <option key={o.value} value={o.value}>
111
+ {o.label}
112
+ </option>
113
+ ))}
114
+ </select>
115
+ );
116
+ }
117
+
118
+ // ─── Number input ─────────────────────────────────────────────────────────────
119
+
120
+ function SettingNumberInput({
121
+ value,
122
+ onChange,
123
+ min,
124
+ max,
125
+ label,
126
+ }: {
127
+ value: number;
128
+ onChange: (v: number) => void;
129
+ min: number;
130
+ max: number;
131
+ label: string;
132
+ }) {
133
+ return (
134
+ <input
135
+ type="number"
136
+ aria-label={label}
137
+ value={value}
138
+ min={min}
139
+ max={max}
140
+ onChange={(e) => {
141
+ const n = parseInt(e.target.value, 10);
142
+ if (!isNaN(n) && n >= min && n <= max) onChange(n);
143
+ }}
144
+ className="text-xs rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-[color:var(--accent-blue)] font-mono text-center"
145
+ style={{
146
+ backgroundColor: 'var(--bg-surface)',
147
+ color: 'var(--text-main)',
148
+ border: '1px solid var(--bg-overlay)',
149
+ width: 64,
150
+ }}
151
+ />
152
+ );
153
+ }
154
+
155
+ // ─── Section divider label ────────────────────────────────────────────────────
156
+
157
+ function SectionLabel({ label }: { label: string }) {
158
+ return (
159
+ <p className="text-[10px] font-semibold uppercase tracking-widest text-[color:var(--text-muted)] mb-2 mt-1 px-1">
160
+ {label}
161
+ </p>
162
+ );
163
+ }
164
+
165
+ // ─── Keyboard shortcut badge ──────────────────────────────────────────────────
166
+
167
+ function KbdRow({ keys, description }: { keys: string; description: string }) {
168
+ return (
169
+ <div className="flex items-center justify-between py-1.5 px-3 rounded-lg hover:bg-[color:var(--bg-mantle)] transition-colors">
170
+ <span className="text-[12px] text-[color:var(--text-sub)]">{description}</span>
171
+ <span
172
+ className="text-[10px] font-mono px-2 py-0.5 rounded"
173
+ style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-blue)', border: '1px solid var(--bg-overlay)' }}
174
+ >
175
+ {keys}
176
+ </span>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ // ─── Static config (product names — no translation needed) ───────────────────
182
+
183
+ const SHELL_OPTIONS = [
184
+ { value: 'powershell', label: 'PowerShell' },
185
+ { value: 'cmd', label: 'Command Prompt' },
186
+ { value: 'gitbash', label: 'Git Bash' },
187
+ { value: 'wsl', label: 'WSL' },
188
+ ];
189
+
190
+ const FONT_FAMILY_OPTIONS = [
191
+ { value: 'Cascadia Code', label: 'Cascadia Code' },
192
+ { value: 'Consolas', label: 'Consolas' },
193
+ { value: 'Fira Code', label: 'Fira Code' },
194
+ { value: 'JetBrains Mono', label: 'JetBrains Mono' },
195
+ ];
196
+
197
+ // ─── Tab content components ───────────────────────────────────────────────────
198
+
199
+ function TabGeneral({
200
+ updateStatus,
201
+ updateMessage,
202
+ onCheckUpdate,
203
+ onInstallUpdate,
204
+ }: {
205
+ updateStatus: UpdateStatus;
206
+ updateMessage: string;
207
+ onCheckUpdate: () => void;
208
+ onInstallUpdate: () => void;
209
+ }) {
210
+ const t = useT();
211
+ const locale = useStore((s) => s.locale);
212
+ const setLocale = useStore((s) => s.setLocale);
213
+
214
+ const defaultShell = useStore((s) => s.defaultShell);
215
+ const setDefaultShell = useStore((s) => s.setDefaultShell);
216
+ const scrollbackLines = useStore((s) => s.scrollbackLines);
217
+ const setScrollbackLines = useStore((s) => s.setScrollbackLines);
218
+
219
+ const updateButtonLabel = () => {
220
+ switch (updateStatus) {
221
+ case 'checking': return t('settings.checking');
222
+ case 'up-to-date': return t('settings.upToDate');
223
+ case 'available': return t('settings.installUpdate');
224
+ case 'error': return t('settings.retryCheck');
225
+ default: return t('settings.checkUpdate');
226
+ }
227
+ };
228
+
229
+ return (
230
+ <div className="flex flex-col gap-4">
231
+ {/* Language */}
232
+ <div>
233
+ <SectionLabel label={t('settings.language')} />
234
+ <div className="grid grid-cols-2 gap-2">
235
+ {LOCALE_OPTIONS.map(({ value, label }) => (
236
+ <button
237
+ key={value}
238
+ onClick={() => setLocale(value as Locale)}
239
+ className="px-3 py-2 rounded-lg text-sm transition-colors text-left"
240
+ style={{
241
+ backgroundColor: locale === value ? 'var(--bg-surface)' : 'transparent',
242
+ color: locale === value ? 'var(--text-main)' : 'var(--text-subtle)',
243
+ border: `1px solid ${locale === value ? 'var(--accent-blue)' : 'var(--bg-surface)'}`,
244
+ }}
245
+ >
246
+ <span className="mr-2">{localeFlag(value as Locale)}</span>
247
+ {label}
248
+ </button>
249
+ ))}
250
+ </div>
251
+ </div>
252
+
253
+ {/* Shell & scrollback */}
254
+ <div className="flex flex-col gap-2">
255
+ <SectionLabel label={t('settings.terminal')} />
256
+ <SettingRow label={t('settings.defaultShell')}>
257
+ <SettingSelect
258
+ label={t('settings.defaultShell')}
259
+ value={defaultShell}
260
+ onChange={setDefaultShell}
261
+ options={SHELL_OPTIONS}
262
+ />
263
+ </SettingRow>
264
+ <SettingRow label={t('settings.scrollbackLines')} description={t('settings.scrollbackDesc')}>
265
+ <SettingNumberInput
266
+ label={t('settings.scrollbackLines')}
267
+ value={scrollbackLines}
268
+ onChange={setScrollbackLines}
269
+ min={1000}
270
+ max={100000}
271
+ />
272
+ </SettingRow>
273
+ </div>
274
+
275
+ {/* Updates */}
276
+ <div>
277
+ <SectionLabel label={t('settings.updates')} />
278
+ <div
279
+ className="px-3 py-2.5 rounded-lg flex items-center justify-between"
280
+ style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
281
+ >
282
+ <div>
283
+ <p className="text-sm text-[color:var(--text-main)]">{t('settings.wmuxUpdates')}</p>
284
+ {updateStatus === 'up-to-date' && (
285
+ <p className="text-[11px] text-[color:var(--accent-green)] mt-0.5">{t('settings.upToDate')}</p>
286
+ )}
287
+ {updateStatus === 'available' && (
288
+ <p className="text-[11px] text-[color:var(--accent-blue)] mt-0.5">{updateMessage || t('settings.updateAvailable')}</p>
289
+ )}
290
+ {updateStatus === 'error' && (
291
+ <p className="text-[11px] text-[color:var(--accent-red)] mt-0.5">{updateMessage || t('settings.updateFailed')}</p>
292
+ )}
293
+ {updateStatus === 'idle' && (
294
+ <p className="text-[11px] text-[color:var(--text-muted)] mt-0.5">{t('settings.lastCheckedNever')}</p>
295
+ )}
296
+ </div>
297
+ <button
298
+ onClick={updateStatus === 'available' ? onInstallUpdate : onCheckUpdate}
299
+ disabled={updateStatus === 'checking'}
300
+ className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 ml-3"
301
+ style={{
302
+ backgroundColor: updateStatus === 'available' ? 'var(--accent-green)' : 'var(--bg-surface)',
303
+ color: updateStatus === 'available' ? 'var(--bg-base)' : 'var(--text-main)',
304
+ }}
305
+ >
306
+ {updateStatus === 'checking'
307
+ ? (
308
+ <span className="flex items-center gap-1.5">
309
+ <span className="animate-spin inline-block w-3 h-3 border border-current border-t-transparent rounded-full" />
310
+ {t('settings.checking')}
311
+ </span>
312
+ )
313
+ : updateButtonLabel()
314
+ }
315
+ </button>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ );
320
+ }
321
+
322
+ function TabAppearance() {
323
+ const t = useT();
324
+ const terminalFontSize = useStore((s) => s.terminalFontSize);
325
+ const setTerminalFontSize = useStore((s) => s.setTerminalFontSize);
326
+ const terminalFontFamily = useStore((s) => s.terminalFontFamily);
327
+ const setTerminalFontFamily = useStore((s) => s.setTerminalFontFamily);
328
+
329
+ const sidebarPosition = useStore((s) => s.sidebarPosition);
330
+ const setSidebarPosition = useStore((s) => s.setSidebarPosition);
331
+
332
+ const currentTheme = useStore((s) => s.theme);
333
+ const setTheme = useStore((s) => s.setTheme);
334
+
335
+ return (
336
+ <div className="flex flex-col gap-4">
337
+ {/* Theme */}
338
+ <div className="flex flex-col gap-2">
339
+ <SectionLabel label="Theme" />
340
+ <div className="grid grid-cols-3 gap-2">
341
+ {THEME_OPTIONS.map(({ value, label }) => (
342
+ <button
343
+ key={value}
344
+ onClick={() => setTheme(value)}
345
+ className="px-3 py-3 rounded-lg text-xs transition-colors text-center"
346
+ style={{
347
+ backgroundColor: currentTheme === value ? 'var(--bg-surface)' : 'transparent',
348
+ color: currentTheme === value ? 'var(--text-main)' : 'var(--text-subtle)',
349
+ border: `1px solid ${currentTheme === value ? 'var(--accent-blue)' : 'var(--bg-surface)'}`,
350
+ }}
351
+ >
352
+ {label}
353
+ </button>
354
+ ))}
355
+ </div>
356
+ </div>
357
+
358
+ <div className="flex flex-col gap-2">
359
+ <SectionLabel label={t('settings.terminal')} />
360
+ <SettingRow label={t('settings.fontSize')} description={`${terminalFontSize}px — ${t('settings.fontSizeRange')}`}>
361
+ <div className="flex items-center gap-2">
362
+ <input
363
+ type="range"
364
+ min={12}
365
+ max={24}
366
+ value={terminalFontSize}
367
+ onChange={(e) => setTerminalFontSize(Number(e.target.value))}
368
+ aria-label={t('settings.fontSize')}
369
+ className="w-24 accent-[color:var(--accent-blue)]"
370
+ />
371
+ <span className="text-xs font-mono text-[color:var(--text-sub)] w-6 text-right">{terminalFontSize}</span>
372
+ </div>
373
+ </SettingRow>
374
+ <SettingRow label={t('settings.fontFamily')} description={t('settings.fontFamilyDesc')}>
375
+ <SettingSelect
376
+ label={t('settings.fontFamily')}
377
+ value={terminalFontFamily}
378
+ onChange={setTerminalFontFamily}
379
+ options={FONT_FAMILY_OPTIONS}
380
+ />
381
+ </SettingRow>
382
+ </div>
383
+
384
+ <div className="flex flex-col gap-2">
385
+ <SectionLabel label={t('settings.layout')} />
386
+ <SettingRow label={t('settings.sidebarPosition')} description={t('settings.sidebarPositionDesc')}>
387
+ <div className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--bg-overlay)' }}>
388
+ {(['left', 'right'] as const).map((pos) => (
389
+ <button
390
+ key={pos}
391
+ onClick={() => setSidebarPosition(pos)}
392
+ className="px-3 py-1 text-xs font-mono transition-colors"
393
+ style={{
394
+ backgroundColor: sidebarPosition === pos ? 'var(--accent-blue)' : 'var(--bg-surface)',
395
+ color: sidebarPosition === pos ? 'var(--bg-base)' : 'var(--text-subtle)',
396
+ }}
397
+ >
398
+ {pos === 'left' ? t('settings.sidebarLeft') : t('settings.sidebarRight')}
399
+ </button>
400
+ ))}
401
+ </div>
402
+ </SettingRow>
403
+ </div>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ function TabNotifications() {
409
+ const t = useT();
410
+ const notificationSoundEnabled = useStore((s) => s.notificationSoundEnabled);
411
+ const toggleNotificationSound = useStore((s) => s.toggleNotificationSound);
412
+ const toastEnabled = useStore((s) => s.toastEnabled);
413
+ const setToastEnabled = useStore((s) => s.setToastEnabled);
414
+ const notificationRingEnabled = useStore((s) => s.notificationRingEnabled);
415
+ const setNotificationRingEnabled = useStore((s) => s.setNotificationRingEnabled);
416
+
417
+ return (
418
+ <div className="flex flex-col gap-2">
419
+ <SectionLabel label={t('settings.notificationBehavior')} />
420
+ <SettingRow label={t('settings.sound')} description={t('settings.soundDesc')}>
421
+ <Toggle
422
+ checked={notificationSoundEnabled}
423
+ onChange={() => toggleNotificationSound()}
424
+ label={t('settings.sound')}
425
+ />
426
+ </SettingRow>
427
+ <SettingRow label={t('settings.toast')} description={t('settings.toastDesc')}>
428
+ <Toggle
429
+ checked={toastEnabled}
430
+ onChange={setToastEnabled}
431
+ label={t('settings.toast')}
432
+ />
433
+ </SettingRow>
434
+ <SettingRow label={t('settings.ring')} description={t('settings.ringDesc')}>
435
+ <Toggle
436
+ checked={notificationRingEnabled}
437
+ onChange={setNotificationRingEnabled}
438
+ label={t('settings.ring')}
439
+ />
440
+ </SettingRow>
441
+ </div>
442
+ );
443
+ }
444
+
445
+ // ─── Key capture overlay ──────────────────────────────────────────────────────
446
+
447
+ function KeyCaptureOverlay({ label, onCapture, onCancel }: { label: string; onCapture: (key: string) => void; onCancel: () => void }) {
448
+ useEffect(() => {
449
+ const handler = (e: KeyboardEvent) => {
450
+ e.preventDefault();
451
+ e.stopPropagation();
452
+ if (e.key === 'Escape') { onCancel(); return; }
453
+
454
+ const parts: string[] = [];
455
+ if (e.ctrlKey) parts.push('Ctrl');
456
+ if (e.shiftKey) parts.push('Shift');
457
+ if (e.altKey) parts.push('Alt');
458
+ let k = e.key;
459
+ if (k.length === 1) k = k.toUpperCase();
460
+ if (!['Control', 'Shift', 'Alt', 'Meta'].includes(k)) {
461
+ parts.push(k);
462
+ onCapture(parts.join('+'));
463
+ }
464
+ };
465
+ window.addEventListener('keydown', handler, true);
466
+ return () => window.removeEventListener('keydown', handler, true);
467
+ }, [onCapture, onCancel]);
468
+
469
+ return (
470
+ <div
471
+ className="fixed inset-0 z-[60] flex items-center justify-center"
472
+ style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}
473
+ onClick={onCancel}
474
+ >
475
+ <div
476
+ className="px-8 py-6 rounded-xl text-center"
477
+ style={{ backgroundColor: 'var(--bg-base)', border: '2px solid var(--accent-blue)', boxShadow: '0 0 30px rgba(137,180,250,0.3)' }}
478
+ onClick={(e) => e.stopPropagation()}
479
+ >
480
+ <p className="text-lg text-[color:var(--text-main)] font-mono mb-2">{label}</p>
481
+ <p className="text-xs text-[color:var(--text-muted)]">ESC to cancel</p>
482
+ </div>
483
+ </div>
484
+ );
485
+ }
486
+
487
+ // ─── Shortcuts tab ────────────────────────────────────────────────────────────
488
+
489
+ function TabShortcuts() {
490
+ const t = useT();
491
+
492
+ const customKeybindings = useStore((s) => s.customKeybindings);
493
+ const addKeybinding = useStore((s) => s.addKeybinding);
494
+ const updateKeybinding = useStore((s) => s.updateKeybinding);
495
+ const removeKeybinding = useStore((s) => s.removeKeybinding);
496
+ const [capturingFor, setCapturingFor] = useState<string | null>(null);
497
+
498
+ const BUILTIN_KEYS = new Set([
499
+ 'Ctrl+B', 'Ctrl+N', 'Ctrl+D', 'Ctrl+T', 'Ctrl+W', 'Ctrl+F',
500
+ 'Ctrl+K', 'Ctrl+I', 'Ctrl+,',
501
+ 'Ctrl+Shift+W', 'Ctrl+Shift+D', 'Ctrl+Shift+L', 'Ctrl+Shift+X',
502
+ 'Ctrl+Shift+H', 'Ctrl+Shift+R', 'Ctrl+Shift+U', 'Ctrl+Shift+O',
503
+ 'Ctrl+Shift+]', 'Ctrl+Shift+[', 'Ctrl+Shift+M',
504
+ ]);
505
+
506
+ const shortcuts = [
507
+ { keys: 'Ctrl+B', description: t('settings.sc.toggleSidebar') },
508
+ { keys: 'Ctrl+D', description: t('settings.sc.splitHorizontal') },
509
+ { keys: 'Ctrl+Shift+D', description: t('settings.sc.splitVertical') },
510
+ { keys: 'Ctrl+T', description: t('settings.sc.newWorkspace') },
511
+ { keys: 'Ctrl+W', description: t('settings.sc.closePane') },
512
+ { keys: 'Ctrl+F', description: t('settings.sc.searchTerminal') },
513
+ { keys: 'Ctrl+K', description: t('settings.sc.commandPalette') },
514
+ { keys: 'Ctrl+I', description: t('settings.sc.toggleNotifications') },
515
+ { keys: 'Ctrl+Shift+L', description: t('settings.sc.viCopyMode') },
516
+ { keys: 'Ctrl+Shift+X', description: t('settings.sc.renameWorkspace') },
517
+ { keys: 'Ctrl+Shift+H', description: t('settings.sc.highlightPane') },
518
+ ];
519
+
520
+ return (
521
+ <div className="flex flex-col gap-1">
522
+ <SectionLabel label={t('settings.shortcuts')} />
523
+ <div
524
+ className="rounded-lg overflow-hidden py-1"
525
+ style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
526
+ >
527
+ {shortcuts.map((s) => (
528
+ <KbdRow key={s.keys} keys={s.keys} description={s.description} />
529
+ ))}
530
+ </div>
531
+ <p className="text-[10px] text-[color:var(--text-muted)] mt-2 px-1">
532
+ {t('settings.shortcutsNotAvailable')}
533
+ </p>
534
+
535
+ {/* Custom keybindings */}
536
+ <SectionLabel label={t('settings.customKeybindings')} />
537
+
538
+ {customKeybindings.length === 0 ? (
539
+ <p className="text-[11px] text-[color:var(--text-muted)] px-1">{t('settings.kb.noBindings')}</p>
540
+ ) : (
541
+ <div className="flex flex-col gap-1.5">
542
+ {customKeybindings.map((kb) => (
543
+ <div
544
+ key={kb.id}
545
+ className="flex items-center gap-2 px-3 py-2 rounded-lg"
546
+ style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
547
+ >
548
+ {/* Key badge */}
549
+ <button
550
+ className="text-[10px] font-mono px-2 py-0.5 rounded shrink-0"
551
+ style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-blue)', border: '1px solid var(--bg-overlay)', minWidth: 60, textAlign: 'center' }}
552
+ onClick={() => setCapturingFor(kb.id)}
553
+ >
554
+ {kb.key}
555
+ </button>
556
+
557
+ {/* Conflict warning */}
558
+ {BUILTIN_KEYS.has(kb.key) && (
559
+ <span className="text-[9px] text-[color:var(--accent-yellow)] shrink-0" title={t('settings.kb.conflict')}>!</span>
560
+ )}
561
+
562
+ {/* Label */}
563
+ <input
564
+ className="flex-1 bg-transparent text-xs text-[color:var(--text-main)] outline-none min-w-0 font-mono"
565
+ style={{ maxWidth: 100 }}
566
+ value={kb.label}
567
+ onChange={(e) => updateKeybinding(kb.id, { label: e.target.value })}
568
+ placeholder={t('settings.kb.label')}
569
+ onClick={(e) => e.stopPropagation()}
570
+ />
571
+
572
+ {/* Command */}
573
+ <input
574
+ className="flex-[2] bg-transparent text-xs text-[color:var(--text-sub2)] outline-none min-w-0 font-mono"
575
+ value={kb.command}
576
+ onChange={(e) => updateKeybinding(kb.id, { command: e.target.value })}
577
+ placeholder={t('settings.kb.command')}
578
+ onClick={(e) => e.stopPropagation()}
579
+ />
580
+
581
+ {/* Send Enter toggle */}
582
+ <Toggle
583
+ checked={kb.sendEnter}
584
+ onChange={(v) => updateKeybinding(kb.id, { sendEnter: v })}
585
+ label={t('settings.kb.sendEnter')}
586
+ />
587
+
588
+ {/* Delete */}
589
+ <button
590
+ className="text-[color:var(--text-subtle)] hover:text-[color:var(--accent-red)] text-xs transition-colors shrink-0"
591
+ onClick={() => removeKeybinding(kb.id)}
592
+ title={t('settings.kb.delete')}
593
+ >
594
+
595
+ </button>
596
+ </div>
597
+ ))}
598
+ </div>
599
+ )}
600
+
601
+ {/* Add button */}
602
+ <button
603
+ className="mt-2 px-3 py-1.5 rounded-lg text-xs font-mono transition-colors"
604
+ style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-green)', border: '1px solid var(--bg-overlay)' }}
605
+ onClick={() => setCapturingFor('new')}
606
+ >
607
+ + {t('settings.kb.add')}
608
+ </button>
609
+
610
+ {/* Key capture overlay */}
611
+ {capturingFor && (
612
+ <KeyCaptureOverlay
613
+ label={t('settings.kb.pressKey')}
614
+ onCapture={(key) => {
615
+ if (capturingFor === 'new') {
616
+ addKeybinding({ key, label: '', command: '', sendEnter: true });
617
+ } else {
618
+ updateKeybinding(capturingFor, { key });
619
+ }
620
+ setCapturingFor(null);
621
+ }}
622
+ onCancel={() => setCapturingFor(null)}
623
+ />
624
+ )}
625
+ </div>
626
+ );
627
+ }
628
+
629
+ function TabAbout() {
630
+ const t = useT();
631
+
632
+ return (
633
+ <div className="flex flex-col gap-4">
634
+ <div
635
+ className="flex flex-col items-center gap-3 py-6 rounded-lg"
636
+ style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
637
+ >
638
+ <span className="text-3xl font-bold font-mono tracking-widest text-[color:var(--text-main)]">WMUX</span>
639
+ <div className="flex flex-col items-center gap-1">
640
+ <span
641
+ className="text-xs font-mono px-2 py-0.5 rounded"
642
+ style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-blue)', border: '1px solid var(--bg-overlay)' }}
643
+ >
644
+ v1.0.0
645
+ </span>
646
+ <p className="text-[11px] text-[color:var(--text-muted)] mt-1">
647
+ {t('settings.aboutTagline')}
648
+ </p>
649
+ </div>
650
+ </div>
651
+
652
+ <div className="flex flex-col gap-2">
653
+ <SectionLabel label={t('settings.builtWith')} />
654
+ <div
655
+ className="px-3 py-2.5 rounded-lg flex flex-col gap-1.5"
656
+ style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
657
+ >
658
+ {[
659
+ 'Electron 41',
660
+ 'React 19 + TypeScript 5.9',
661
+ 'xterm.js 6 + node-pty',
662
+ 'Vite 5 + Tailwind CSS 3',
663
+ 'Zustand 5 + Immer',
664
+ ].map((item) => (
665
+ <div key={item} className="flex items-center gap-2">
666
+ <span className="text-[color:var(--accent-green)] text-[10px]">▸</span>
667
+ <span className="text-[12px] text-[color:var(--text-sub)] font-mono">{item}</span>
668
+ </div>
669
+ ))}
670
+ </div>
671
+ </div>
672
+
673
+ <div>
674
+ <SectionLabel label={t('settings.links')} />
675
+ <a
676
+ href="https://github.com"
677
+ target="_blank"
678
+ rel="noopener noreferrer"
679
+ className="flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-[color:var(--accent-blue)] hover:text-[color:var(--text-main)] transition-colors"
680
+ style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
681
+ >
682
+ <span>⎋</span>
683
+ <span>{t('settings.githubRepo')}</span>
684
+ </a>
685
+ </div>
686
+ </div>
687
+ );
688
+ }
689
+
690
+ // ─── SettingsPanel ─────────────────────────────────────────────────────────────
691
+
692
+ export default function SettingsPanel() {
693
+ const t = useT();
694
+ const visible = useStore((s) => s.settingsPanelVisible);
695
+ const setVisible = useStore((s) => s.setSettingsPanelVisible);
696
+
697
+ const [activeTab, setActiveTab] = useState<TabId>('general');
698
+ const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
699
+ const [updateMessage, setUpdateMessage] = useState('');
700
+ const panelRef = useRef<HTMLDivElement>(null);
701
+
702
+ const tabs: { id: TabId; label: string; icon: string }[] = [
703
+ { id: 'general', label: t('settings.tabGeneral'), icon: '⚙' },
704
+ { id: 'appearance', label: t('settings.tabAppearance'), icon: '◑' },
705
+ { id: 'notifications', label: t('settings.tabNotifications'), icon: '◎' },
706
+ { id: 'shortcuts', label: t('settings.tabShortcuts'), icon: '⌨' },
707
+ { id: 'about', label: t('settings.tabAbout'), icon: 'ℹ' },
708
+ ];
709
+
710
+ // Listen for update events from main process
711
+ useEffect(() => {
712
+ const unsubAvailable = window.electronAPI.updater.onUpdateAvailable((data) => {
713
+ if (data.status === 'downloaded') {
714
+ setUpdateStatus('available');
715
+ setUpdateMessage(data.releaseName ?? t('settings.updateReady'));
716
+ } else {
717
+ setUpdateStatus('available');
718
+ setUpdateMessage(data.releaseName ?? t('settings.updateAvailable'));
719
+ }
720
+ });
721
+
722
+ const unsubNotAvail = window.electronAPI.updater.onUpdateNotAvailable(() => {
723
+ setUpdateStatus('up-to-date');
724
+ setUpdateMessage('');
725
+ });
726
+
727
+ const unsubError = window.electronAPI.updater.onUpdateError((data) => {
728
+ setUpdateStatus('error');
729
+ setUpdateMessage(data.message ?? t('settings.unknownError'));
730
+ });
731
+
732
+ return () => {
733
+ unsubAvailable();
734
+ unsubNotAvail();
735
+ unsubError();
736
+ };
737
+ }, []);
738
+
739
+ // Close on Escape
740
+ useEffect(() => {
741
+ if (!visible) return;
742
+ const handler = (e: KeyboardEvent) => {
743
+ if (e.key === 'Escape') {
744
+ e.stopPropagation();
745
+ setVisible(false);
746
+ }
747
+ };
748
+ window.addEventListener('keydown', handler, { capture: true });
749
+ return () => window.removeEventListener('keydown', handler, { capture: true });
750
+ }, [visible, setVisible]);
751
+
752
+ const handleCheckUpdate = useCallback(async () => {
753
+ setUpdateStatus('checking');
754
+ setUpdateMessage('');
755
+ try {
756
+ const result = await window.electronAPI.updater.checkForUpdates();
757
+ if (result.status === 'not-available') {
758
+ setUpdateStatus('up-to-date');
759
+ }
760
+ } catch {
761
+ setUpdateStatus('error');
762
+ setUpdateMessage(t('settings.checkFailed'));
763
+ }
764
+ }, []);
765
+
766
+ const handleInstallUpdate = useCallback(async () => {
767
+ await window.electronAPI.updater.installUpdate();
768
+ }, []);
769
+
770
+ if (!visible) return null;
771
+
772
+ return (
773
+ // Backdrop
774
+ <div
775
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[8vh]"
776
+ style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
777
+ onMouseDown={(e) => {
778
+ if (e.target === e.currentTarget) setVisible(false);
779
+ }}
780
+ >
781
+ {/* Panel — 600x450 */}
782
+ <div
783
+ ref={panelRef}
784
+ className="flex flex-col rounded-xl overflow-hidden shadow-2xl"
785
+ style={{
786
+ width: 600,
787
+ height: 450,
788
+ backgroundColor: 'var(--bg-base)',
789
+ border: '1px solid var(--bg-surface)',
790
+ boxShadow: '0 25px 60px rgba(0,0,0,0.75)',
791
+ }}
792
+ onMouseDown={(e) => e.stopPropagation()}
793
+ >
794
+ {/* Header */}
795
+ <div
796
+ className="flex items-center justify-between px-5 py-3 shrink-0"
797
+ style={{ borderBottom: '1px solid var(--bg-surface)' }}
798
+ >
799
+ <span className="text-sm font-semibold text-[color:var(--text-main)] font-mono tracking-wide">{t('settings.title')}</span>
800
+ <button
801
+ className="text-[color:var(--text-subtle)] hover:text-[color:var(--text-main)] transition-colors"
802
+ onClick={() => setVisible(false)}
803
+ aria-label={t('settings.close')}
804
+ >
805
+ <IconX />
806
+ </button>
807
+ </div>
808
+
809
+ {/* Body: left nav + right content */}
810
+ <div className="flex flex-1 min-h-0">
811
+ {/* Left tab navigation */}
812
+ <nav
813
+ className="flex flex-col gap-0.5 py-3 px-2 shrink-0"
814
+ style={{
815
+ width: 140,
816
+ borderRight: '1px solid var(--bg-surface)',
817
+ backgroundColor: 'var(--bg-mantle)',
818
+ }}
819
+ >
820
+ {tabs.map((tab) => {
821
+ const isActive = activeTab === tab.id;
822
+ return (
823
+ <button
824
+ key={tab.id}
825
+ onClick={() => setActiveTab(tab.id)}
826
+ className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-left transition-colors text-[12px]"
827
+ style={{
828
+ backgroundColor: isActive ? 'var(--bg-surface)' : 'transparent',
829
+ color: isActive ? 'var(--text-main)' : 'var(--text-subtle)',
830
+ fontWeight: isActive ? 600 : 400,
831
+ }}
832
+ >
833
+ <span className="text-[13px] leading-none" style={{ color: isActive ? 'var(--accent-blue)' : 'var(--text-muted)' }}>
834
+ {tab.icon}
835
+ </span>
836
+ {tab.label}
837
+ </button>
838
+ );
839
+ })}
840
+ </nav>
841
+
842
+ {/* Right content */}
843
+ <div className="flex-1 overflow-y-auto px-5 py-4">
844
+ {activeTab === 'general' && (
845
+ <TabGeneral
846
+ updateStatus={updateStatus}
847
+ updateMessage={updateMessage}
848
+ onCheckUpdate={handleCheckUpdate}
849
+ onInstallUpdate={handleInstallUpdate}
850
+ />
851
+ )}
852
+ {activeTab === 'appearance' && <TabAppearance />}
853
+ {activeTab === 'notifications' && <TabNotifications />}
854
+ {activeTab === 'shortcuts' && <TabShortcuts />}
855
+ {activeTab === 'about' && <TabAbout />}
856
+ </div>
857
+ </div>
858
+
859
+ {/* Footer */}
860
+ <div
861
+ className="flex items-center justify-between px-5 py-2.5 shrink-0"
862
+ style={{ borderTop: '1px solid var(--bg-surface)', backgroundColor: 'var(--bg-mantle)' }}
863
+ >
864
+ <span className="text-[10px] text-[color:var(--text-muted)] font-mono">{t('settings.toggleHint')}</span>
865
+ <button
866
+ className="text-xs text-[color:var(--text-subtle)] hover:text-[color:var(--text-main)] transition-colors"
867
+ onClick={() => setVisible(false)}
868
+ >
869
+ {t('settings.close')}
870
+ </button>
871
+ </div>
872
+ </div>
873
+ </div>
874
+ );
875
+ }
876
+
877
+ // ─── Locale flag helper ───────────────────────────────────────────────────────
878
+
879
+ function localeFlag(locale: Locale): string {
880
+ switch (locale) {
881
+ case 'en': return '🇺🇸';
882
+ case 'ko': return '🇰🇷';
883
+ case 'ja': return '🇯🇵';
884
+ case 'zh': return '🇨🇳';
885
+ }
886
+ }