@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,349 @@
1
+ import { useEffect, useRef, useCallback } from 'react';
2
+ import { Terminal } from '@xterm/xterm';
3
+ import { FitAddon } from '@xterm/addon-fit';
4
+ import { WebglAddon } from '@xterm/addon-webgl';
5
+ import { SearchAddon } from '@xterm/addon-search';
6
+ import { useStore } from '../stores';
7
+ import { t } from '../i18n';
8
+ import { XTERM_THEMES, type ThemeId } from '../themes';
9
+
10
+ // Lightweight copy feedback toast — injects/removes a DOM element
11
+ let copyToastTimer: ReturnType<typeof setTimeout> | null = null;
12
+ function showCopyToast() {
13
+ let el = document.getElementById('wmux-copy-toast');
14
+ if (!el) {
15
+ el = document.createElement('div');
16
+ el.id = 'wmux-copy-toast';
17
+ el.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%);background:#a6e3a1;color:#1e1e2e;font-family:monospace;font-size:11px;font-weight:600;padding:3px 12px;border-radius:4px;z-index:9999;pointer-events:none;opacity:0;transition:opacity 0.2s';
18
+ document.body.appendChild(el);
19
+ }
20
+ el.textContent = t('terminal.copied');
21
+ el.style.opacity = '1';
22
+ if (copyToastTimer) clearTimeout(copyToastTimer);
23
+ copyToastTimer = setTimeout(() => { el!.style.opacity = '0'; }, 1200);
24
+ }
25
+
26
+ interface UseTerminalOptions {
27
+ ptyId: string | null;
28
+ /** Combined visibility flag: true only when the terminal's workspace AND surface tab are both active.
29
+ * When false the terminal DOM container may be hidden (display:none / zero-size). */
30
+ isVisible?: boolean;
31
+ }
32
+
33
+ export function useTerminal(containerRef: React.RefObject<HTMLDivElement | null>, options: UseTerminalOptions) {
34
+ const terminalRef = useRef<Terminal | null>(null);
35
+ const fitAddonRef = useRef<FitAddon | null>(null);
36
+ const searchAddonRef = useRef<SearchAddon | null>(null);
37
+ const { ptyId, isVisible = true } = options;
38
+ const terminalFontSize = useStore((s) => s.terminalFontSize);
39
+ const terminalFontFamily = useStore((s) => s.terminalFontFamily);
40
+ const scrollbackLines = useStore((s) => s.scrollbackLines);
41
+ const theme = useStore((s) => s.theme) as ThemeId;
42
+ const xtermTheme = XTERM_THEMES[theme] ?? XTERM_THEMES.catppuccin;
43
+
44
+ const fit = useCallback(() => {
45
+ const container = containerRef.current;
46
+ if (!fitAddonRef.current || !terminalRef.current || !container) return;
47
+ // Guard: skip fit entirely when the container is hidden (zero dimensions).
48
+ // Calling fit() on a display:none element produces 0 cols/rows which
49
+ // corrupts the xterm buffer and causes the "infinite copy downward" bug.
50
+ if (container.offsetWidth === 0 || container.offsetHeight === 0) return;
51
+ try {
52
+ fitAddonRef.current.fit();
53
+ if (ptyId) {
54
+ const { cols, rows } = terminalRef.current;
55
+ // Never send 0-size resize to PTY — that corrupts the terminal buffer.
56
+ if (cols > 0 && rows > 0) {
57
+ window.electronAPI.pty.resize(ptyId, cols, rows);
58
+ }
59
+ }
60
+ } catch {
61
+ // ignore fit errors during unmount
62
+ }
63
+ }, [ptyId, containerRef]);
64
+
65
+ useEffect(() => {
66
+ const container = containerRef.current;
67
+ if (!container || !ptyId) return;
68
+
69
+ const terminal = new Terminal({
70
+ cursorBlink: true,
71
+ fontSize: terminalFontSize,
72
+ scrollback: scrollbackLines,
73
+ scrollOnUserInput: false,
74
+ fontFamily: `'${terminalFontFamily}', 'Consolas', 'Courier New', monospace`,
75
+ theme: xtermTheme,
76
+ allowProposedApi: true,
77
+ });
78
+
79
+ const fitAddon = new FitAddon();
80
+ const searchAddon = new SearchAddon();
81
+ terminal.loadAddon(fitAddon);
82
+ terminal.loadAddon(searchAddon);
83
+ terminal.open(container);
84
+
85
+ // Try WebGL, fall back to canvas
86
+ try {
87
+ const webglAddon = new WebglAddon();
88
+ webglAddon.onContextLoss(() => {
89
+ webglAddon.dispose();
90
+ });
91
+ terminal.loadAddon(webglAddon);
92
+ } catch {
93
+ console.warn('WebGL addon failed, using canvas renderer');
94
+ }
95
+
96
+ // Only fit immediately if the container is actually visible (non-zero size).
97
+ // If the workspace starts hidden (display:none), skip the initial fit so we
98
+ // don't corrupt the terminal with 0 cols/rows. The visibility-watcher effect
99
+ // below will trigger a proper fit when the workspace is shown.
100
+ if (container.offsetWidth > 0 && container.offsetHeight > 0) {
101
+ fitAddon.fit();
102
+ }
103
+
104
+ // Clipboard + shortcut handling
105
+ terminal.attachCustomKeyEventHandler((e) => {
106
+ if (e.type !== 'keydown') return true;
107
+
108
+ // Pass app shortcuts through to useKeyboard (don't let xterm consume them)
109
+ if (e.ctrlKey && !e.shiftKey && [',', 'b', 'k', 'i', 'n', 't'].includes(e.key)) {
110
+ return false; // let DOM bubble to useKeyboard
111
+ }
112
+ if (e.ctrlKey && e.shiftKey) {
113
+ return false; // all Ctrl+Shift combos → app shortcuts
114
+ }
115
+
116
+ // Custom keybindings: let function keys and matched combos pass through to useKeyboard
117
+ const { customKeybindings } = useStore.getState();
118
+ if (customKeybindings.length > 0) {
119
+ const parts: string[] = [];
120
+ if (e.ctrlKey) parts.push('Ctrl');
121
+ if (e.shiftKey) parts.push('Shift');
122
+ if (e.altKey) parts.push('Alt');
123
+ let k = e.key;
124
+ if (k.length === 1) k = k.toUpperCase();
125
+ parts.push(k);
126
+ const combo = parts.join('+');
127
+ if (customKeybindings.some((kb) => kb.key === combo)) {
128
+ return false; // let useKeyboard handle it
129
+ }
130
+ }
131
+
132
+ // Ctrl+C: copy if selection exists, otherwise send SIGINT
133
+ if (e.ctrlKey && !e.shiftKey && e.key === 'c') {
134
+ const sel = terminal.getSelection();
135
+ if (sel) {
136
+ void window.clipboardAPI.writeText(sel);
137
+ terminal.clearSelection();
138
+ showCopyToast();
139
+ return false;
140
+ }
141
+ return true; // no selection → SIGINT
142
+ }
143
+
144
+ // Ctrl+V: paste from clipboard (use our IPC clipboard, block event
145
+ // so xterm doesn't also paste via browser's native paste event)
146
+ if (e.ctrlKey && !e.shiftKey && e.key === 'v') {
147
+ e.preventDefault();
148
+ void window.clipboardAPI.readText().then((text) => {
149
+ if (text) window.electronAPI.pty.write(ptyId, text);
150
+ }).catch(() => {});
151
+ return false;
152
+ }
153
+
154
+ // Ctrl+Shift+C: copy fallback
155
+ if (e.ctrlKey && e.shiftKey && e.key === 'C') {
156
+ const sel = terminal.getSelection();
157
+ if (sel) {
158
+ void window.clipboardAPI.writeText(sel);
159
+ showCopyToast();
160
+ }
161
+ return false;
162
+ }
163
+ // Ctrl+Shift+V: paste fallback
164
+ if (e.ctrlKey && e.shiftKey && e.key === 'V') {
165
+ e.preventDefault();
166
+ void window.clipboardAPI.readText().then((text) => {
167
+ if (text) window.electronAPI.pty.write(ptyId, text);
168
+ }).catch(() => {});
169
+ return false;
170
+ }
171
+
172
+ return true;
173
+ });
174
+
175
+ // Right-click: always paste
176
+ terminal.element?.addEventListener('contextmenu', (e) => {
177
+ e.preventDefault();
178
+ void window.clipboardAPI.readText().then((text) => {
179
+ console.log('[wmux:clipboard] right-click paste len=', text?.length ?? 0);
180
+ if (text) window.electronAPI.pty.write(ptyId, text);
181
+ }).catch((err) => console.error('[wmux:clipboard] right-click error:', err));
182
+ });
183
+
184
+ // Drag-and-drop: paste file paths into terminal
185
+ // Use the xterm element + capture phase — xterm's internal canvas blocks
186
+ // normal event propagation on the container div.
187
+ const xtermEl = terminal.element;
188
+ const handleDragOver = (e: Event) => {
189
+ e.preventDefault();
190
+ e.stopPropagation();
191
+ const de = e as DragEvent;
192
+ if (de.dataTransfer) de.dataTransfer.dropEffect = 'copy';
193
+ };
194
+ const handleDrop = (e: Event) => {
195
+ e.preventDefault();
196
+ e.stopPropagation();
197
+ const de = e as DragEvent;
198
+ const files = de.dataTransfer?.files;
199
+ if (!files || files.length === 0) return;
200
+ const paths: string[] = [];
201
+ for (let i = 0; i < files.length; i++) {
202
+ paths.push((files[i] as File & { path: string }).path);
203
+ }
204
+ const text = paths.map((p) => (p.includes(' ') ? `"${p}"` : p)).join(' ');
205
+ window.electronAPI.pty.write(ptyId, text);
206
+ };
207
+ if (xtermEl) {
208
+ xtermEl.addEventListener('dragenter', handleDragOver, true);
209
+ xtermEl.addEventListener('dragover', handleDragOver, true);
210
+ xtermEl.addEventListener('drop', handleDrop, true);
211
+ }
212
+ // Also on container as fallback
213
+ container.addEventListener('dragenter', handleDragOver, true);
214
+ container.addEventListener('dragover', handleDragOver, true);
215
+ container.addEventListener('drop', handleDrop, true);
216
+
217
+ // Forward user input to PTY
218
+ terminal.onData((data) => {
219
+ window.electronAPI.pty.write(ptyId, data);
220
+ });
221
+
222
+ // Receive PTY output
223
+ const removeDataListener = window.electronAPI.pty.onData((id, data) => {
224
+ if (id === ptyId) {
225
+ terminal.write(data);
226
+ }
227
+ });
228
+
229
+ // Handle PTY exit
230
+ const removeExitListener = window.electronAPI.pty.onExit((id, exitCode) => {
231
+ if (id === ptyId) {
232
+ terminal.writeln(`\r\n${t('terminal.exitedBracket', { code: exitCode })}`);
233
+ }
234
+ });
235
+
236
+ // Resize PTY on initial fit — only when we actually have valid dimensions.
237
+ const { cols, rows } = terminal;
238
+ if (cols > 0 && rows > 0) {
239
+ window.electronAPI.pty.resize(ptyId, cols, rows);
240
+ }
241
+
242
+ terminalRef.current = terminal;
243
+ fitAddonRef.current = fitAddon;
244
+ searchAddonRef.current = searchAddon;
245
+
246
+ // ResizeObserver for auto-fit — preserves user scroll position across resize.
247
+ // IMPORTANT: skip when the container has zero dimensions (display:none workspace).
248
+ // Fitting a hidden terminal produces 0 cols/rows, which corrupts the PTY buffer
249
+ // and manifests as "infinite content duplication" when switching back to it.
250
+ const resizeObserver = new ResizeObserver(() => {
251
+ requestAnimationFrame(() => {
252
+ try {
253
+ const term = terminalRef.current;
254
+ if (!term) return;
255
+
256
+ // Skip entirely if container is hidden/zero-size
257
+ if (container.offsetWidth === 0 || container.offsetHeight === 0) return;
258
+
259
+ // Snapshot scroll state before fit() reflows the buffer
260
+ const prevYBase = term.buffer.active.baseY;
261
+ const prevYDisp = term.buffer.active.viewportY;
262
+ const wasScrolledUp = prevYDisp < prevYBase;
263
+ // How many lines from the bottom was the user?
264
+ const distFromBottom = prevYBase - prevYDisp;
265
+
266
+ fitAddon.fit();
267
+
268
+ // Restore scroll position if user was not at the bottom
269
+ if (wasScrolledUp) {
270
+ const newYBase = term.buffer.active.baseY;
271
+ const targetYDisp = Math.max(0, newYBase - distFromBottom);
272
+ term.scrollToLine(targetYDisp);
273
+ }
274
+
275
+ const { cols, rows } = term;
276
+ // Never send 0-size resize to PTY
277
+ if (cols > 0 && rows > 0) {
278
+ window.electronAPI.pty.resize(ptyId, cols, rows);
279
+ }
280
+ } catch {
281
+ // ignore fit errors during unmount
282
+ }
283
+ });
284
+ });
285
+ resizeObserver.observe(container);
286
+
287
+ return () => {
288
+ if (xtermEl) {
289
+ xtermEl.removeEventListener('dragenter', handleDragOver, true);
290
+ xtermEl.removeEventListener('dragover', handleDragOver, true);
291
+ xtermEl.removeEventListener('drop', handleDrop, true);
292
+ }
293
+ container.removeEventListener('dragenter', handleDragOver, true);
294
+ container.removeEventListener('dragover', handleDragOver, true);
295
+ container.removeEventListener('drop', handleDrop, true);
296
+ resizeObserver.disconnect();
297
+ removeDataListener();
298
+ removeExitListener();
299
+ terminal.dispose();
300
+ terminalRef.current = null;
301
+ fitAddonRef.current = null;
302
+ searchAddonRef.current = null;
303
+ };
304
+ }, [ptyId, containerRef, terminalFontSize, terminalFontFamily, scrollbackLines, xtermTheme]);
305
+
306
+ // Re-fit when the terminal becomes visible (workspace switch or surface tab switch).
307
+ // Without this, a terminal that was initialized while hidden (0-size) will display
308
+ // at the wrong size until the next manual resize.
309
+ useEffect(() => {
310
+ if (!isVisible) return;
311
+ // Defer slightly to allow the CSS display change to take effect before measuring
312
+ const id = requestAnimationFrame(() => {
313
+ fit();
314
+ });
315
+ return () => cancelAnimationFrame(id);
316
+ }, [isVisible, fit]);
317
+
318
+ const findNext = useCallback((text: string) => {
319
+ searchAddonRef.current?.findNext(text, {
320
+ decorations: {
321
+ matchBackground: '#f9e2af40',
322
+ matchBorder: '#f9e2af',
323
+ matchOverviewRuler: '#f9e2af',
324
+ activeMatchBackground: '#f9e2af80',
325
+ activeMatchBorder: '#f9e2af',
326
+ activeMatchColorOverviewRuler: '#f9e2af',
327
+ },
328
+ });
329
+ }, []);
330
+
331
+ const findPrevious = useCallback((text: string) => {
332
+ searchAddonRef.current?.findPrevious(text, {
333
+ decorations: {
334
+ matchBackground: '#f9e2af40',
335
+ matchBorder: '#f9e2af',
336
+ matchOverviewRuler: '#f9e2af',
337
+ activeMatchBackground: '#f9e2af80',
338
+ activeMatchBorder: '#f9e2af',
339
+ activeMatchColorOverviewRuler: '#f9e2af',
340
+ },
341
+ });
342
+ }, []);
343
+
344
+ const clearSearch = useCallback(() => {
345
+ searchAddonRef.current?.clearDecorations();
346
+ }, []);
347
+
348
+ return { terminal: terminalRef, fit, searchAddonRef, findNext, findPrevious, clearSearch };
349
+ }
@@ -0,0 +1,320 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+ import type { Terminal } from '@xterm/xterm';
3
+
4
+ export interface CursorPosition {
5
+ row: number;
6
+ col: number;
7
+ }
8
+
9
+ export interface UseViCopyModeOptions {
10
+ terminal: Terminal | null;
11
+ isActive: boolean;
12
+ }
13
+
14
+ export interface UseViCopyModeReturn {
15
+ cursorRow: number;
16
+ cursorCol: number;
17
+ selectionStart: CursorPosition | null;
18
+ selectionEnd: CursorPosition | null;
19
+ isVisual: boolean;
20
+ handleKey: (key: string, ctrlKey?: boolean) => void;
21
+ exit: () => void;
22
+ copySelection: () => Promise<void>;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Buffer helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function getLineText(terminal: Terminal, row: number): string {
30
+ const line = terminal.buffer.active.getLine(row);
31
+ if (!line) return '';
32
+ return line.translateToString(true);
33
+ }
34
+
35
+ function totalRows(terminal: Terminal): number {
36
+ return terminal.buffer.active.length;
37
+ }
38
+
39
+ function clampRow(row: number, terminal: Terminal): number {
40
+ return Math.max(0, Math.min(totalRows(terminal) - 1, row));
41
+ }
42
+
43
+ function clampCol(col: number, text: string): number {
44
+ const max = Math.max(0, text.length - 1);
45
+ return Math.max(0, Math.min(max, col));
46
+ }
47
+
48
+ function nextWordStart(text: string, col: number): number {
49
+ let i = col + 1;
50
+ while (i < text.length && text[i] !== ' ') i++;
51
+ while (i < text.length && text[i] === ' ') i++;
52
+ return Math.max(0, Math.min(i, text.length - 1));
53
+ }
54
+
55
+ function prevWordStart(text: string, col: number): number {
56
+ let i = col - 1;
57
+ while (i > 0 && text[i] === ' ') i--;
58
+ while (i > 0 && text[i - 1] !== ' ') i--;
59
+ return Math.max(0, i);
60
+ }
61
+
62
+ function linearOffset(terminal: Terminal, row: number, col: number): number {
63
+ let off = 0;
64
+ for (let r = 0; r < row; r++) off += getLineText(terminal, r).length + 1;
65
+ return off + col;
66
+ }
67
+
68
+ function normalizeRange(
69
+ terminal: Terminal,
70
+ a: CursorPosition,
71
+ b: CursorPosition,
72
+ ): [CursorPosition, CursorPosition] {
73
+ if (linearOffset(terminal, a.row, a.col) <= linearOffset(terminal, b.row, b.col)) {
74
+ return [a, b];
75
+ }
76
+ return [b, a];
77
+ }
78
+
79
+ function buildSelectionText(
80
+ terminal: Terminal,
81
+ start: CursorPosition,
82
+ end: CursorPosition,
83
+ ): string {
84
+ const [s, e] = normalizeRange(terminal, start, end);
85
+ const lines: string[] = [];
86
+ for (let r = s.row; r <= e.row; r++) {
87
+ const text = getLineText(terminal, r);
88
+ if (r === s.row && r === e.row) {
89
+ lines.push(text.slice(s.col, e.col + 1));
90
+ } else if (r === s.row) {
91
+ lines.push(text.slice(s.col));
92
+ } else if (r === e.row) {
93
+ lines.push(text.slice(0, e.col + 1));
94
+ } else {
95
+ lines.push(text);
96
+ }
97
+ }
98
+ return lines.join('\n');
99
+ }
100
+
101
+ function applyXtermSelection(
102
+ terminal: Terminal,
103
+ start: CursorPosition,
104
+ end: CursorPosition,
105
+ ): void {
106
+ const [s, e] = normalizeRange(terminal, start, end);
107
+ if (s.row === e.row) {
108
+ terminal.select(s.col, s.row, e.col - s.col + 1);
109
+ return;
110
+ }
111
+ const startText = getLineText(terminal, s.row);
112
+ let length = startText.length - s.col + 1; // +1 for implicit newline
113
+ for (let r = s.row + 1; r < e.row; r++) {
114
+ length += getLineText(terminal, r).length + 1;
115
+ }
116
+ length += e.col + 1;
117
+ terminal.select(s.col, s.row, length);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Hook
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export function useViCopyMode(
125
+ options: UseViCopyModeOptions,
126
+ onExit: () => void,
127
+ ): UseViCopyModeReturn {
128
+ const { terminal, isActive } = options;
129
+
130
+ const [cursor, setCursor] = useState<CursorPosition>({ row: 0, col: 0 });
131
+ const [isVisual, setIsVisual] = useState(false);
132
+ const [visualStart, setVisualStart] = useState<CursorPosition | null>(null);
133
+
134
+ // Pending 'g' for 'gg' sequence
135
+ const ggPending = useRef(false);
136
+
137
+ // Stable refs so callbacks don't stale-close over state
138
+ const cursorRef = useRef(cursor);
139
+ cursorRef.current = cursor;
140
+ const isVisualRef = useRef(isVisual);
141
+ isVisualRef.current = isVisual;
142
+ const visualStartRef = useRef(visualStart);
143
+ visualStartRef.current = visualStart;
144
+
145
+ // Initialize cursor when copy mode becomes active
146
+ useEffect(() => {
147
+ if (!isActive || !terminal) return;
148
+ const buf = terminal.buffer.active;
149
+ const row = clampRow(buf.viewportY + buf.cursorY, terminal);
150
+ const col = Math.max(0, buf.cursorX);
151
+ setCursor({ row, col });
152
+ setIsVisual(false);
153
+ setVisualStart(null);
154
+ ggPending.current = false;
155
+ terminal.clearSelection();
156
+ }, [isActive, terminal]);
157
+
158
+ const exit = useCallback(() => {
159
+ if (terminal) terminal.clearSelection();
160
+ setIsVisual(false);
161
+ setVisualStart(null);
162
+ ggPending.current = false;
163
+ onExit();
164
+ }, [terminal, onExit]);
165
+
166
+ const copySelection = useCallback(async () => {
167
+ if (!terminal) return;
168
+ const text = terminal.getSelection() || getLineText(terminal, cursorRef.current.row);
169
+ if (text) await navigator.clipboard.writeText(text);
170
+ exit();
171
+ }, [terminal, exit]);
172
+
173
+ const handleKey = useCallback(
174
+ (key: string, ctrlKey = false) => {
175
+ if (!terminal || !isActive) return;
176
+
177
+ const viewportRows = terminal.rows;
178
+
179
+ // ESC / q always exit
180
+ if (!ctrlKey && (key === 'Escape' || key === 'q')) {
181
+ exit();
182
+ return;
183
+ }
184
+
185
+ // y: yank then exit
186
+ if (!ctrlKey && key === 'y') {
187
+ const cur = cursorRef.current;
188
+ const vs = visualStartRef.current;
189
+ const iv = isVisualRef.current;
190
+ let text: string;
191
+ if (iv && vs) {
192
+ text = buildSelectionText(terminal, vs, cur);
193
+ } else {
194
+ text = getLineText(terminal, cur.row);
195
+ }
196
+ navigator.clipboard.writeText(text).then(() => exit());
197
+ return;
198
+ }
199
+
200
+ // v: toggle visual mode
201
+ if (!ctrlKey && key === 'v') {
202
+ if (isVisualRef.current) {
203
+ terminal.clearSelection();
204
+ setIsVisual(false);
205
+ setVisualStart(null);
206
+ } else {
207
+ setIsVisual(true);
208
+ setVisualStart({ ...cursorRef.current });
209
+ }
210
+ ggPending.current = false;
211
+ return;
212
+ }
213
+
214
+ setCursor((prev) => {
215
+ let { row, col } = prev;
216
+
217
+ if (ctrlKey) {
218
+ if (key === 'u') row = clampRow(row - Math.floor(viewportRows / 2), terminal);
219
+ else if (key === 'd') row = clampRow(row + Math.floor(viewportRows / 2), terminal);
220
+ else return prev;
221
+
222
+ const lt = getLineText(terminal, row);
223
+ col = clampCol(col, lt);
224
+ terminal.scrollToLine(row);
225
+ const next = { row, col };
226
+ if (isVisualRef.current && visualStartRef.current) {
227
+ applyXtermSelection(terminal, visualStartRef.current, next);
228
+ }
229
+ ggPending.current = false;
230
+ return next;
231
+ }
232
+
233
+ const lineText = getLineText(terminal, row);
234
+
235
+ switch (key) {
236
+ case 'h': {
237
+ col = Math.max(0, col - 1);
238
+ break;
239
+ }
240
+ case 'l': {
241
+ col = Math.min(Math.max(0, lineText.length - 1), col + 1);
242
+ break;
243
+ }
244
+ case 'j': {
245
+ row = clampRow(row + 1, terminal);
246
+ col = clampCol(col, getLineText(terminal, row));
247
+ terminal.scrollToLine(row);
248
+ break;
249
+ }
250
+ case 'k': {
251
+ row = clampRow(row - 1, terminal);
252
+ col = clampCol(col, getLineText(terminal, row));
253
+ terminal.scrollToLine(row);
254
+ break;
255
+ }
256
+ case 'w': {
257
+ col = nextWordStart(lineText, col);
258
+ break;
259
+ }
260
+ case 'b': {
261
+ col = prevWordStart(lineText, col);
262
+ break;
263
+ }
264
+ case '0': {
265
+ col = 0;
266
+ break;
267
+ }
268
+ case '$': {
269
+ col = Math.max(0, lineText.length - 1);
270
+ break;
271
+ }
272
+ case 'g': {
273
+ if (ggPending.current) {
274
+ // gg: go to buffer start
275
+ row = 0;
276
+ col = 0;
277
+ terminal.scrollToLine(0);
278
+ ggPending.current = false;
279
+ } else {
280
+ ggPending.current = true;
281
+ return prev; // wait for second g
282
+ }
283
+ break;
284
+ }
285
+ case 'G': {
286
+ row = clampRow(totalRows(terminal) - 1, terminal);
287
+ col = Math.max(0, getLineText(terminal, row).length - 1);
288
+ terminal.scrollToLine(row);
289
+ ggPending.current = false;
290
+ break;
291
+ }
292
+ default: {
293
+ if (key !== 'g') ggPending.current = false;
294
+ return prev;
295
+ }
296
+ }
297
+
298
+ if (key !== 'g') ggPending.current = false;
299
+
300
+ const next = { row, col };
301
+ if (isVisualRef.current && visualStartRef.current) {
302
+ applyXtermSelection(terminal, visualStartRef.current, next);
303
+ }
304
+ return next;
305
+ });
306
+ },
307
+ [terminal, isActive, exit],
308
+ );
309
+
310
+ return {
311
+ cursorRow: cursor.row,
312
+ cursorCol: cursor.col,
313
+ selectionStart: isVisual ? visualStart : null,
314
+ selectionEnd: isVisual ? cursor : null,
315
+ isVisual,
316
+ handleKey,
317
+ exit,
318
+ copySelection,
319
+ };
320
+ }