@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,67 @@
1
+ import { useStore } from '../../stores';
2
+ import { useT } from '../../hooks/useT';
3
+
4
+ export default function MiniSidebar() {
5
+ const t = useT();
6
+ const sidebarPosition = useStore((s) => s.sidebarPosition);
7
+ const workspaces = useStore((s) => s.workspaces);
8
+ const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
9
+ const setActiveWorkspace = useStore((s) => s.setActiveWorkspace);
10
+ const toggleSidebar = useStore((s) => s.toggleSidebar);
11
+ const totalUnread = useStore((s) =>
12
+ s.notifications.filter((n) => !n.read).length,
13
+ );
14
+
15
+ return (
16
+ <div className={`flex flex-col h-full bg-[var(--bg-mantle)] ${sidebarPosition === 'right' ? 'border-l' : 'border-r'} border-[var(--bg-surface)]`} style={{ width: 48 }}>
17
+ {/* Expand button */}
18
+ <button
19
+ className="flex items-center justify-center h-10 text-[var(--text-muted)] hover:text-[var(--text-main)] transition-colors border-b border-[var(--bg-surface)] font-mono text-[11px]"
20
+ onClick={toggleSidebar}
21
+ title={t('sidebar.expandTooltip')}
22
+ >
23
+
24
+ </button>
25
+
26
+ {/* Workspace dots */}
27
+ <div className="flex-1 overflow-y-auto py-2 flex flex-col items-center gap-1">
28
+ {workspaces.map((ws, i) => {
29
+ const isActive = ws.id === activeWorkspaceId;
30
+ const initial = ws.name.charAt(0).toUpperCase();
31
+
32
+ return (
33
+ <button
34
+ key={ws.id}
35
+ className={`w-8 h-8 rounded-md flex items-center justify-center text-xs font-bold font-mono transition-colors ${
36
+ isActive
37
+ ? 'bg-[var(--bg-surface)] text-[var(--text-main)]'
38
+ : 'text-[var(--text-muted)] hover:bg-[rgba(var(--bg-surface-rgb),0.5)] hover:text-[var(--text-sub)]'
39
+ }`}
40
+ onClick={() => setActiveWorkspace(ws.id)}
41
+ title={`${ws.name} (Ctrl+${i + 1})`}
42
+ >
43
+ {initial}
44
+ </button>
45
+ );
46
+ })}
47
+ </div>
48
+
49
+ {/* Status area */}
50
+ <div className="flex flex-col items-center gap-2 py-2 border-t border-[var(--bg-surface)]">
51
+ {/* Unread badge */}
52
+ {totalUnread > 0 && (
53
+ <button
54
+ className="w-8 h-8 rounded-md flex items-center justify-center bg-[rgba(var(--accent-blue-rgb),0.2)] text-[var(--accent-blue)] text-[10px] font-bold"
55
+ onClick={() => useStore.getState().toggleNotificationPanel()}
56
+ title={t('sidebar.unreadCount', { count: totalUnread })}
57
+ >
58
+ {totalUnread > 99 ? '99+' : totalUnread}
59
+ </button>
60
+ )}
61
+
62
+ {/* Workspace count */}
63
+ <span className="text-[9px] font-mono text-[var(--text-muted)]">{workspaces.length}</span>
64
+ </div>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,84 @@
1
+ import { useStore } from '../../stores';
2
+ import WorkspaceItem from './WorkspaceItem';
3
+ import type { Pane } from '../../../shared/types';
4
+ import { useT } from '../../hooks/useT';
5
+
6
+ // Pane 트리에서 모든 leaf의 PTY를 dispose
7
+ function disposeAllPtys(pane: Pane) {
8
+ if (pane.type === 'leaf') {
9
+ for (const s of pane.surfaces) {
10
+ if (s.ptyId) window.electronAPI.pty.dispose(s.ptyId);
11
+ }
12
+ } else {
13
+ for (const child of pane.children) disposeAllPtys(child);
14
+ }
15
+ }
16
+
17
+ export default function Sidebar() {
18
+ const t = useT();
19
+ const sidebarPosition = useStore((s) => s.sidebarPosition);
20
+ const workspaces = useStore((s) => s.workspaces);
21
+ const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
22
+ const addWorkspace = useStore((s) => s.addWorkspace);
23
+ const removeWorkspace = useStore((s) => s.removeWorkspace);
24
+ const setActiveWorkspace = useStore((s) => s.setActiveWorkspace);
25
+ const renameWorkspace = useStore((s) => s.renameWorkspace);
26
+ const reorderWorkspace = useStore((s) => s.reorderWorkspace);
27
+ const handleCtrlSelect = (wsId: string) => {
28
+ // Just switch to the workspace (don't split)
29
+ setActiveWorkspace(wsId);
30
+ };
31
+
32
+ const handleClose = (wsId: string) => {
33
+ // 삭제 전 해당 워크스페이스의 모든 PTY 정리
34
+ const ws = workspaces.find((w) => w.id === wsId);
35
+ if (ws) disposeAllPtys(ws.rootPane);
36
+
37
+ removeWorkspace(wsId);
38
+ };
39
+
40
+ return (
41
+ <div className={`flex flex-col h-full bg-[var(--bg-mantle)] ${sidebarPosition === 'right' ? 'border-l' : 'border-r'} border-[var(--bg-surface)]`} style={{ width: 240 }}>
42
+ {/* Header */}
43
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--bg-surface)]">
44
+ <span className="text-sm font-bold text-[var(--text-main)] tracking-widest font-mono">WMUX</span>
45
+ <button
46
+ className="text-[var(--text-subtle)] hover:text-[var(--accent-green)] text-lg leading-none transition-colors"
47
+ onClick={() => addWorkspace()}
48
+ title={t('sidebar.newWorkspaceTooltip')}
49
+ >
50
+ +
51
+ </button>
52
+ </div>
53
+
54
+ {/* Workspace list */}
55
+ <div className="flex-1 overflow-y-auto py-2 space-y-0.5">
56
+ {workspaces.map((ws, i) => (
57
+ <WorkspaceItem
58
+ key={ws.id}
59
+ workspace={ws}
60
+ isActive={ws.id === activeWorkspaceId}
61
+ index={i}
62
+ onSelect={() => setActiveWorkspace(ws.id)}
63
+ onCtrlSelect={() => handleCtrlSelect(ws.id)}
64
+ onRename={(name) => renameWorkspace(ws.id, name)}
65
+ onClose={() => handleClose(ws.id)}
66
+ onReorder={reorderWorkspace}
67
+ />
68
+ ))}
69
+ </div>
70
+
71
+ {/* Footer */}
72
+ <div className="flex items-center justify-between px-4 py-2 border-t border-[var(--bg-surface)] text-[10px] font-mono text-[var(--text-muted)]">
73
+ <span>{workspaces.length} {t('sidebar.workspaces')}</span>
74
+ <button
75
+ className="text-[var(--text-muted)] hover:text-[var(--text-main)] transition-colors"
76
+ onClick={() => useStore.getState().toggleSidebar()}
77
+ title={t('sidebar.hideTooltip')}
78
+ >
79
+
80
+ </button>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,241 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import type { AgentStatus, Workspace } from '../../../shared/types';
3
+ import { useStore } from '../../stores';
4
+ import { useT } from '../../hooks/useT';
5
+
6
+ interface WorkspaceItemProps {
7
+ workspace: Workspace;
8
+ isActive: boolean;
9
+ index: number;
10
+ onSelect: () => void;
11
+ onCtrlSelect: () => void;
12
+ onRename: (name: string) => void;
13
+ onClose: () => void;
14
+ onReorder: (fromIndex: number, toIndex: number) => void;
15
+ }
16
+
17
+ // Maps AgentStatus to a colored dot indicator character and Tailwind color class.
18
+ const AGENT_STATUS_ICON: Record<AgentStatus, { dot: string; className: string; labelKey: string }> = {
19
+ running: { dot: '●', className: 'text-[var(--accent-blue)]', labelKey: 'workspace.agentRunning' },
20
+ complete: { dot: '●', className: 'text-[var(--accent-green)]', labelKey: 'workspace.agentComplete' },
21
+ error: { dot: '●', className: 'text-[var(--accent-red)]', labelKey: 'workspace.agentError' },
22
+ waiting: { dot: '●', className: 'text-[var(--accent-yellow)]', labelKey: 'workspace.agentWaiting' },
23
+ idle: { dot: '●', className: 'text-[var(--text-muted)]', labelKey: 'workspace.agentIdle' },
24
+ };
25
+
26
+ function AgentStatusDot({ status, agentName }: { status: AgentStatus; agentName?: string }): React.ReactElement {
27
+ const t = useT();
28
+ const icon = AGENT_STATUS_ICON[status];
29
+ const label = t(icon.labelKey);
30
+ return (
31
+ <span
32
+ className={`text-[8px] leading-none flex-shrink-0 ${icon.className} ${status === 'running' ? 'animate-pulse' : ''}`}
33
+ title={agentName ? `${agentName} — ${label}` : label}
34
+ >
35
+ {icon.dot}
36
+ </span>
37
+ );
38
+ }
39
+
40
+ function shortenPath(path: string, maxLen = 25): string {
41
+ if (!path || path.length <= maxLen) return path;
42
+ const parts = path.replace(/\\/g, '/').split('/');
43
+ if (parts.length <= 2) return path;
44
+ return `.../${parts.slice(-2).join('/')}`;
45
+ }
46
+
47
+ export default function WorkspaceItem({ workspace, isActive, index, onSelect, onCtrlSelect, onRename, onClose, onReorder }: WorkspaceItemProps) {
48
+ const t = useT();
49
+ const [editing, setEditing] = useState(false);
50
+ const [editName, setEditName] = useState(workspace.name);
51
+ const [isDragging, setIsDragging] = useState(false);
52
+ const [dropIndicator, setDropIndicator] = useState<'above' | 'below' | null>(null);
53
+ const inputRef = useRef<HTMLInputElement>(null);
54
+ const dragStartTimeRef = useRef<number>(0);
55
+
56
+ const unreadCount = useStore((s) =>
57
+ s.notifications.filter((n) => !n.read && n.workspaceId === workspace.id).length,
58
+ );
59
+
60
+ const metadata = workspace.metadata;
61
+
62
+ useEffect(() => {
63
+ if (editing) inputRef.current?.focus();
64
+ }, [editing]);
65
+
66
+ const commitRename = () => {
67
+ const trimmed = editName.trim();
68
+ if (trimmed && trimmed !== workspace.name) {
69
+ onRename(trimmed);
70
+ } else {
71
+ setEditName(workspace.name);
72
+ }
73
+ setEditing(false);
74
+ };
75
+
76
+ const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
77
+ dragStartTimeRef.current = Date.now();
78
+ e.dataTransfer.setData('text/plain', String(index));
79
+ e.dataTransfer.effectAllowed = 'move';
80
+ // 약간의 딜레이 후 dragging 상태로 전환 (더블클릭 구분)
81
+ setTimeout(() => setIsDragging(true), 0);
82
+ };
83
+
84
+ const handleDragEnd = () => {
85
+ setIsDragging(false);
86
+ setDropIndicator(null);
87
+ };
88
+
89
+ const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
90
+ e.preventDefault();
91
+ e.dataTransfer.dropEffect = 'move';
92
+ const rect = e.currentTarget.getBoundingClientRect();
93
+ const midY = rect.top + rect.height / 2;
94
+ setDropIndicator(e.clientY < midY ? 'above' : 'below');
95
+ };
96
+
97
+ const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
98
+ // currentTarget 밖으로 나갈 때만 인디케이터 제거
99
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
100
+ setDropIndicator(null);
101
+ }
102
+ };
103
+
104
+ const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
105
+ e.preventDefault();
106
+ setDropIndicator(null);
107
+ const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
108
+ if (isNaN(fromIndex) || fromIndex === index) return;
109
+
110
+ // 드롭 위치를 아이템 중간 기준으로 결정
111
+ // 위 절반 → 현재 index 앞으로, 아래 절반 → 현재 index 뒤로
112
+ const rect = e.currentTarget.getBoundingClientRect();
113
+ const midY = rect.top + rect.height / 2;
114
+ const toIndex = e.clientY < midY
115
+ ? (fromIndex < index ? index - 1 : index)
116
+ : (fromIndex > index ? index + 1 : index);
117
+ onReorder(fromIndex, toIndex);
118
+ };
119
+
120
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
121
+ // 드래그 직후 클릭 이벤트 무시 (200ms 이내)
122
+ if (Date.now() - dragStartTimeRef.current < 200) return;
123
+ if (e.ctrlKey) {
124
+ e.preventDefault();
125
+ onCtrlSelect();
126
+ } else {
127
+ onSelect();
128
+ }
129
+ };
130
+
131
+ const handleDoubleClick = () => {
132
+ // 드래그 직후 더블클릭 이벤트 무시
133
+ if (Date.now() - dragStartTimeRef.current < 300) return;
134
+ setEditName(workspace.name);
135
+ setEditing(true);
136
+ };
137
+
138
+ return (
139
+ <div className="relative mx-2">
140
+ {/* 드롭 인디케이터 - 위 */}
141
+ {dropIndicator === 'above' && (
142
+ <div className="absolute top-0 left-0 right-0 h-0.5 bg-[var(--accent-blue)] rounded-full z-10 -translate-y-px" />
143
+ )}
144
+
145
+ <div
146
+ draggable
147
+ className={`group flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-md transition-colors select-none ${
148
+ isActive
149
+ ? 'bg-[var(--bg-surface)] text-[var(--text-main)]'
150
+ : 'text-[var(--text-subtle)] hover:bg-[rgba(var(--bg-surface-rgb),0.5)] hover:text-[var(--text-sub)]'
151
+ } ${isDragging ? 'opacity-40' : 'opacity-100'}`}
152
+ onClick={handleClick}
153
+ onDoubleClick={handleDoubleClick}
154
+ onDragStart={handleDragStart}
155
+ onDragEnd={handleDragEnd}
156
+ onDragOver={handleDragOver}
157
+ onDragLeave={handleDragLeave}
158
+ onDrop={handleDrop}
159
+ >
160
+ {/* Status indicator */}
161
+ <div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5 ${isActive ? 'bg-[var(--accent-green)]' : 'bg-[var(--text-muted)]'}`} />
162
+
163
+ {/* Name + Metadata */}
164
+ <div className="flex-1 min-w-0">
165
+ {editing ? (
166
+ <input
167
+ ref={inputRef}
168
+ className="w-full bg-[var(--bg-base)] text-[var(--text-main)] text-[11px] font-mono px-1 py-0 rounded border border-[var(--text-muted)] outline-none"
169
+ value={editName}
170
+ onChange={(e) => setEditName(e.target.value)}
171
+ onBlur={commitRename}
172
+ onKeyDown={(e) => {
173
+ if (e.key === 'Enter') commitRename();
174
+ if (e.key === 'Escape') { setEditName(workspace.name); setEditing(false); }
175
+ }}
176
+ onClick={(e) => e.stopPropagation()}
177
+ />
178
+ ) : (
179
+ <>
180
+ <div className="flex items-center gap-1">
181
+ <span className="text-[11px] font-mono truncate">{workspace.name}</span>
182
+ {metadata?.agentStatus && metadata.agentStatus !== 'idle' && (
183
+ <AgentStatusDot status={metadata.agentStatus} agentName={metadata.agentName} />
184
+ )}
185
+ {unreadCount > 0 && (
186
+ <span className="bg-[var(--accent-blue)] text-[var(--bg-base)] text-[9px] font-bold min-w-[16px] h-4 flex items-center justify-center rounded-full px-1 flex-shrink-0">
187
+ {unreadCount}
188
+ </span>
189
+ )}
190
+ </div>
191
+ {metadata && (metadata.gitBranch || metadata.cwd || (metadata.listeningPorts && metadata.listeningPorts.length > 0) || metadata.agentName) && (
192
+ <div className="mt-0.5 space-y-0 text-[10px] text-[var(--text-muted)]">
193
+ {metadata.gitBranch && (
194
+ <div className="truncate" title={metadata.gitBranch}>
195
+ <span className="text-[var(--accent-yellow)]">⎇</span> {metadata.gitBranch}
196
+ </div>
197
+ )}
198
+ {metadata.cwd && (
199
+ <div className="truncate" title={metadata.cwd}>
200
+ {shortenPath(metadata.cwd)}
201
+ </div>
202
+ )}
203
+ {metadata.listeningPorts && metadata.listeningPorts.length > 0 && (
204
+ <div className="truncate">
205
+ <span className="text-[var(--accent-green)]">●</span> :{metadata.listeningPorts.slice(0, 3).join(', :')}
206
+ {metadata.listeningPorts.length > 3 && ` +${metadata.listeningPorts.length - 3}`}
207
+ </div>
208
+ )}
209
+ {metadata.agentName && (
210
+ <div className="truncate" title={`${metadata.agentName}`}>
211
+ <span className="text-[var(--accent-purple)]">⚡</span> {metadata.agentName}
212
+ </div>
213
+ )}
214
+ </div>
215
+ )}
216
+ </>
217
+ )}
218
+ </div>
219
+
220
+ {/* Shortcut hint */}
221
+ <span className="text-[8px] font-mono text-[var(--text-muted)] flex-shrink-0 mt-0.5">
222
+ {index < 9 ? `^${index + 1}` : ''}
223
+ </span>
224
+
225
+ {/* Close button */}
226
+ <button
227
+ className="opacity-0 group-hover:opacity-100 text-[var(--text-subtle)] hover:text-[var(--accent-red)] text-[10px] font-mono flex-shrink-0 mt-0.5 transition-opacity"
228
+ onClick={(e) => { e.stopPropagation(); onClose(); }}
229
+ title={t('workspace.close')}
230
+ >
231
+
232
+ </button>
233
+ </div>
234
+
235
+ {/* 드롭 인디케이터 - 아래 */}
236
+ {dropIndicator === 'below' && (
237
+ <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--accent-blue)] rounded-full z-10 translate-y-px" />
238
+ )}
239
+ </div>
240
+ );
241
+ }
@@ -0,0 +1,93 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useStore } from '../../stores';
3
+ import { useT } from '../../hooks/useT';
4
+
5
+ export default function StatusBar() {
6
+ const t = useT();
7
+ const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
8
+ const workspaces = useStore((s) => s.workspaces);
9
+ const activeWs = workspaces.find((w) => w.id === activeWorkspaceId);
10
+ const unreadCount = useStore((s) => s.notifications.filter((n) => !n.read).length);
11
+ const toggleSettingsPanel = useStore((s) => s.toggleSettingsPanel);
12
+
13
+ // Company 모드 비용 정보
14
+ const sidebarMode = useStore((s) => s.sidebarMode);
15
+ const totalCost = useStore((s) => s.company?.totalCostEstimate ?? 0);
16
+ const sessionStartTime = useStore((s) => s.sessionStartTime);
17
+
18
+ const [time, setTime] = useState(new Date());
19
+ const [memUsage, setMemUsage] = useState('');
20
+ const [sessionMin, setSessionMin] = useState(0);
21
+
22
+ // Update clock every second
23
+ useEffect(() => {
24
+ const timer = setInterval(() => {
25
+ setTime(new Date());
26
+ if (sessionStartTime) {
27
+ setSessionMin(Math.floor((Date.now() - sessionStartTime) / 60_000));
28
+ }
29
+ }, 1000);
30
+ return () => clearInterval(timer);
31
+ }, [sessionStartTime]);
32
+
33
+ // Update memory usage every 5 seconds
34
+ useEffect(() => {
35
+ const update = () => {
36
+ const perf = performance as unknown as { memory?: { usedJSHeapSize: number } };
37
+ if (perf.memory) {
38
+ setMemUsage(`${Math.round(perf.memory.usedJSHeapSize / 1024 / 1024)}MB`);
39
+ }
40
+ };
41
+ update();
42
+ const timer = setInterval(update, 5000);
43
+ return () => clearInterval(timer);
44
+ }, []);
45
+
46
+ const timeStr = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
47
+ const branch = activeWs?.metadata?.gitBranch;
48
+ const isCompanyMode = sidebarMode === 'company';
49
+
50
+ return (
51
+ <div className="flex items-center justify-between h-6 px-3 bg-[#11111b] border-b border-[var(--bg-surface)] text-[10px] text-[var(--text-muted)] shrink-0 select-none font-mono">
52
+ {/* Left: workspace + branch */}
53
+ <div className="flex items-center gap-3">
54
+ <span className="text-[var(--text-main)] font-medium">{activeWs?.name || 'wmux'}</span>
55
+ {branch && (
56
+ <span>
57
+ <span className="text-[var(--accent-yellow)]">⎇</span> {branch}
58
+ </span>
59
+ )}
60
+ {/* Company 모드 배지 */}
61
+ {isCompanyMode && (
62
+ <span className="text-[8px] font-mono px-1.5 py-px bg-[var(--bg-surface)] text-[var(--accent-blue)] rounded">
63
+ {t('statusBar.company')}
64
+ </span>
65
+ )}
66
+ </div>
67
+
68
+ {/* Right: status indicators */}
69
+ <div className="flex items-center gap-3">
70
+ {/* Company 모드일 때 비용 표시 */}
71
+ {isCompanyMode && (
72
+ <span className="text-[var(--text-sub2)]" title={t('statusBar.session', { min: sessionMin })}>
73
+ ~${totalCost.toFixed(2)}
74
+ </span>
75
+ )}
76
+ {unreadCount > 0 && (
77
+ <span className="text-[var(--accent-blue)]">
78
+ ● {unreadCount}
79
+ </span>
80
+ )}
81
+ {memUsage && <span>{memUsage}</span>}
82
+ <span>{timeStr}</span>
83
+ <button
84
+ onClick={toggleSettingsPanel}
85
+ className="text-[var(--text-muted)] hover:text-[var(--text-main)] transition-colors ml-1"
86
+ title={t('statusBar.settingsTooltip')}
87
+ >
88
+
89
+ </button>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,126 @@
1
+ import { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { useT } from '../../hooks/useT';
3
+
4
+ interface SearchBarProps {
5
+ onFindNext: (text: string) => void;
6
+ onFindPrevious: (text: string) => void;
7
+ onClose: () => void;
8
+ }
9
+
10
+ export default function SearchBar({ onFindNext, onFindPrevious, onClose }: SearchBarProps) {
11
+ const t = useT();
12
+ const [query, setQuery] = useState('');
13
+ const inputRef = useRef<HTMLInputElement>(null);
14
+
15
+ // 마운트 시 입력 필드에 포커스
16
+ useEffect(() => {
17
+ inputRef.current?.focus();
18
+ }, []);
19
+
20
+ // ESC 키로 닫기
21
+ useEffect(() => {
22
+ const handler = (e: KeyboardEvent) => {
23
+ if (e.key === 'Escape') {
24
+ e.preventDefault();
25
+ onClose();
26
+ }
27
+ };
28
+ window.addEventListener('keydown', handler);
29
+ return () => window.removeEventListener('keydown', handler);
30
+ }, [onClose]);
31
+
32
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
33
+ if (e.key === 'Enter') {
34
+ e.preventDefault();
35
+ if (e.shiftKey) {
36
+ onFindPrevious(query);
37
+ } else {
38
+ onFindNext(query);
39
+ }
40
+ }
41
+ }, [query, onFindNext, onFindPrevious]);
42
+
43
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
44
+ setQuery(e.target.value);
45
+ }, []);
46
+
47
+ return (
48
+ <div
49
+ className="absolute top-0 right-2 z-50 flex items-center gap-1 px-2 py-1.5 rounded-b-md shadow-lg"
50
+ style={{
51
+ background: 'var(--bg-surface)',
52
+ border: '1px solid var(--bg-overlay)',
53
+ borderTop: 'none',
54
+ minWidth: '280px',
55
+ }}
56
+ // 클릭이 Pane의 handleClick까지 버블링되지 않도록 차단
57
+ onClick={(e) => e.stopPropagation()}
58
+ >
59
+ {/* 검색 아이콘 */}
60
+ <svg
61
+ width="13"
62
+ height="13"
63
+ viewBox="0 0 16 16"
64
+ fill="none"
65
+ className="shrink-0 text-[var(--text-subtle)]"
66
+ style={{ color: 'var(--text-subtle)' }}
67
+ >
68
+ <circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
69
+ <line x1="10" y1="10" x2="14" y2="14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
70
+ </svg>
71
+
72
+ {/* 입력 필드 */}
73
+ <input
74
+ ref={inputRef}
75
+ type="text"
76
+ value={query}
77
+ onChange={handleChange}
78
+ onKeyDown={handleKeyDown}
79
+ placeholder={t('search.placeholder')}
80
+ className="flex-1 bg-transparent outline-none text-xs"
81
+ style={{
82
+ color: 'var(--text-main)',
83
+ caretColor: '#f5e0dc',
84
+ minWidth: 0,
85
+ }}
86
+ spellCheck={false}
87
+ />
88
+
89
+ {/* 이전 버튼 (Shift+Enter) */}
90
+ <button
91
+ onClick={() => onFindPrevious(query)}
92
+ title={t('search.prevTooltip')}
93
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors hover:bg-[var(--bg-overlay)] text-[var(--text-sub2)] hover:text-[var(--text-main)] shrink-0"
94
+ >
95
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
96
+ <path d="M5 8L2 5l3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
97
+ <path d="M8 8L5 5l3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
98
+ </svg>
99
+ </button>
100
+
101
+ {/* 다음 버튼 (Enter) */}
102
+ <button
103
+ onClick={() => onFindNext(query)}
104
+ title={t('search.nextTooltip')}
105
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors hover:bg-[var(--bg-overlay)] text-[var(--text-sub2)] hover:text-[var(--text-main)] shrink-0"
106
+ >
107
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
108
+ <path d="M2 2l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
109
+ <path d="M5 2l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
110
+ </svg>
111
+ </button>
112
+
113
+ {/* 닫기 버튼 */}
114
+ <button
115
+ onClick={onClose}
116
+ title={t('search.closeTooltip')}
117
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors hover:bg-[var(--bg-overlay)] text-[var(--text-subtle)] hover:text-[var(--accent-red)] shrink-0"
118
+ >
119
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
120
+ <line x1="2" y1="2" x2="8" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
121
+ <line x1="8" y1="2" x2="2" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
122
+ </svg>
123
+ </button>
124
+ </div>
125
+ );
126
+ }