synabun 1.3.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 (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,1022 @@
1
+ /**
2
+ * html-term-renderer.js — Custom HTML/CSS terminal renderer
3
+ *
4
+ * Replaces xterm.js for CLI tool profiles (claude-code, codex, gemini).
5
+ * Converts VTermBuffer state to styled DOM elements. Handles keyboard
6
+ * input, selection, scrollback with native scrollbar, and resize.
7
+ *
8
+ * Performance optimizations:
9
+ * - Pre-cached FG/BG class name lookup tables (zero allocation per cell)
10
+ * - Batched HTML string building with zero intermediate arrays
11
+ * - Passive scroll listener to avoid blocking compositor
12
+ * - Resize debouncing to prevent layout thrashing
13
+ * - Cached cell dimensions to avoid repeated measurements
14
+ * - Row content hashing to skip unchanged rows during full re-renders
15
+ * - Smooth auto-scroll with requestAnimationFrame batching
16
+ * - Write batching: multiple write() calls per frame are flushed once in rAF
17
+ * - Live-mode scroll bypass: skips spacer/scrollTop updates when pinned to bottom
18
+ * - Deferred spacer sync: spacer height only updated when user scrolls up
19
+ *
20
+ * Usage:
21
+ * const renderer = new HtmlTermRenderer(container, ws, { onTitle, onResize });
22
+ * // WS messages: renderer.write(data)
23
+ * // Cleanup: renderer.dispose()
24
+ */
25
+
26
+ import { VTermBuffer } from './vterm-buffer.js';
27
+
28
+ // ─── Theme (must match XTERM_THEME in ui-terminal.js) ───
29
+ const THEME = {
30
+ bg: '#0a0a0c',
31
+ fg: '#d0d0d0',
32
+ cursor: '#6eb5ff',
33
+ selBg: 'rgba(110,181,255,0.3)',
34
+ selFg: '#ffffff',
35
+ };
36
+
37
+ // Pre-cached class name lookup tables — avoids string concat per cell
38
+ const FG_CLASSES = [];
39
+ const BG_CLASSES = [];
40
+ for (let i = 0; i < 16; i++) {
41
+ FG_CLASSES[i] = `ht-fg${i}`;
42
+ BG_CLASSES[i] = `ht-bg${i}`;
43
+ }
44
+
45
+ // HTML entity map for single-char escaping
46
+ const HTML_ENTITIES = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
47
+
48
+ // Resize debounce (ms) — prevents layout thrashing during continuous resize.
49
+ // 150ms is tuned for Ink/React TUIs that re-render their entire screen on SIGWINCH.
50
+ const RESIZE_DEBOUNCE_MS = 150;
51
+
52
+ export class HtmlTermRenderer {
53
+ /**
54
+ * @param {HTMLElement} container — mount point (will be filled)
55
+ * @param {WebSocket|null} ws — WebSocket to PTY (null = deferred connect)
56
+ * @param {object} [options]
57
+ * @param {function} [options.onTitle] — title change callback
58
+ * @param {function} [options.onResize] — (cols, rows) resize callback
59
+ * @param {number} [options.fontSize] — default 13
60
+ * @param {string} [options.fontFamily] — default JetBrains Mono
61
+ * @param {number} [options.lineHeight] — default 1.3
62
+ */
63
+ constructor(container, ws, options = {}) {
64
+ this._container = container;
65
+ this._ws = ws;
66
+ this._options = options;
67
+ this._disposed = false;
68
+
69
+ this._fontSize = options.fontSize || 13;
70
+ this._fontFamily = options.fontFamily || "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace";
71
+ this._lineHeight = options.lineHeight || 1.3;
72
+
73
+ // Cell dimensions (measured once, cached)
74
+ this._cellWidth = 0;
75
+ this._cellHeight = 0;
76
+ this._cols = 80;
77
+ this._rows = 24;
78
+
79
+ // Buffer (created after measurement)
80
+ this._buffer = null;
81
+
82
+ // Render state
83
+ this._rafId = null;
84
+ this._renderPending = false;
85
+ this._lastTitle = '';
86
+
87
+ // Row content hashes — skip re-rendering unchanged rows on full re-renders
88
+ this._rowHashes = [];
89
+
90
+ // Selection state
91
+ this._selecting = false;
92
+ this._selStart = null;
93
+ this._selEnd = null;
94
+ this._selectionDirty = false;
95
+
96
+ // Auto-scroll state for selection drag
97
+ this._autoScrollRAF = null;
98
+ this._autoScrollSpeed = 0;
99
+ this._lastMouseX = 0;
100
+ this._lastMouseY = 0;
101
+
102
+ // Scroll state
103
+ this._lastFirstVisible = -1;
104
+ this._suppressScrollEvent = false;
105
+
106
+ // Live mode: when true, skip spacer math and always render from buffer bottom.
107
+ // Eliminates the scrollTop/spacer desync that causes blank rows.
108
+ this._isLive = true;
109
+
110
+ // Span pools for DOM diffing — avoids innerHTML replacement per row
111
+ this._rowSpanPools = [];
112
+
113
+ // Write batching — accumulates data from multiple write() calls,
114
+ // flushed once per rAF in _render(). Prevents N reflows/frame → 1.
115
+ this._writeBuf = '';
116
+ this._altTransitionPending = false;
117
+
118
+ // Deferred spacer — only sync spacer height when leaving live mode
119
+ this._spacerDirty = false;
120
+
121
+ // Resize debounce state
122
+ this._resizeTimer = null;
123
+ this._resizePending = false;
124
+
125
+ // Bound handlers (avoids closure allocation on every event)
126
+ this._boundScrollHandler = this._onScroll.bind(this);
127
+
128
+ // Build DOM
129
+ this._buildDOM();
130
+ this._measureCell();
131
+ this._calculateSize();
132
+
133
+ // Create buffer
134
+ this._buffer = new VTermBuffer(this._cols, this._rows);
135
+
136
+ // Wire events
137
+ this._wireKeyboard();
138
+ this._wireMouse();
139
+ this._wireScroll();
140
+ this._wireResize();
141
+
142
+ // Initial render
143
+ this._rebuildRows();
144
+ this._scheduleRender();
145
+ }
146
+
147
+ // ─── Public API ────────────────────────────────────────
148
+
149
+ /** Feed raw PTY data — batched per animation frame to prevent scroll shaking.
150
+ * Multiple write() calls between frames accumulate in _writeBuf and are
151
+ * flushed once in _render(), eliminating per-chunk reflows. */
152
+ write(data) {
153
+ if (this._disposed || !this._buffer) return;
154
+ this._writeBuf += data;
155
+ this._scheduleRender();
156
+ }
157
+
158
+ /** Flush accumulated write buffer through the terminal buffer.
159
+ * Called once per frame from _render() — all spacer/scroll side-effects
160
+ * happen inside the same rAF, eliminating multi-frame desync. */
161
+ _flushWrites() {
162
+ if (!this._writeBuf) return false;
163
+ const data = this._writeBuf;
164
+ this._writeBuf = '';
165
+
166
+ this._buffer.write(data);
167
+
168
+ // Check title change
169
+ if (this._buffer.title !== this._lastTitle) {
170
+ this._lastTitle = this._buffer.title;
171
+ if (this._options.onTitle) this._options.onTitle(this._lastTitle);
172
+ }
173
+
174
+ // Alt screen transition → force live mode
175
+ if (this._buffer._altTransitionPending) {
176
+ this._isLive = true;
177
+ this._buffer._altTransitionPending = false;
178
+ }
179
+
180
+ // Mark spacer as needing update (deferred to scroll-up or transition)
181
+ this._spacerDirty = true;
182
+
183
+ return true;
184
+ }
185
+
186
+ /** Set WebSocket (for deferred connect) */
187
+ setWebSocket(ws) {
188
+ this._ws = ws;
189
+ }
190
+
191
+ /** Recalculate cols/rows from container size */
192
+ fit() {
193
+ if (this._disposed) return;
194
+ this._measureCell();
195
+ const changed = this._calculateSize();
196
+ if (changed) {
197
+ this._buffer.resize(this._cols, this._rows);
198
+ this._rebuildRows();
199
+ this._spacerDirty = true;
200
+ this._isLive = true; // resize snaps back to live view
201
+ this._scheduleRender();
202
+ if (this._options.onResize) {
203
+ this._options.onResize(this._cols, this._rows);
204
+ }
205
+ }
206
+ }
207
+
208
+ focus() {
209
+ if (!this._disposed) this._input.focus();
210
+ }
211
+
212
+ blur() {
213
+ if (!this._disposed) this._input.blur();
214
+ }
215
+
216
+ get cols() { return this._cols; }
217
+ get rows() { return this._rows; }
218
+ get buffer() { return this._buffer; }
219
+
220
+ /** Get selected text */
221
+ getSelection() {
222
+ if (!this._selStart || !this._selEnd) return '';
223
+ const s = this._normalizeSelection();
224
+ if (!s) return '';
225
+ return this._buffer.getText(s.startRow, s.startCol, s.endRow, s.endCol);
226
+ }
227
+
228
+ scrollToBottom() {
229
+ this._isLive = true;
230
+ // Sync spacer before scrolling so scrollHeight is correct
231
+ if (this._spacerDirty) {
232
+ this._updateSpacerHeight();
233
+ this._spacerDirty = false;
234
+ }
235
+ this._scrollToMax();
236
+ this._scheduleRender();
237
+ }
238
+
239
+ dispose() {
240
+ if (this._disposed) return;
241
+ this._disposed = true;
242
+ if (this._rafId) cancelAnimationFrame(this._rafId);
243
+ if (this._ro) this._ro.disconnect();
244
+ if (this._resizeTimer) clearTimeout(this._resizeTimer);
245
+ // Remove scroll listener (was added with passive: true)
246
+ this._scrollEl.removeEventListener('scroll', this._boundScrollHandler);
247
+ this._container.innerHTML = '';
248
+ }
249
+
250
+ // ─── DOM construction ─────────────────────────────────
251
+
252
+ _buildDOM() {
253
+ this._container.innerHTML = '';
254
+
255
+ // Root
256
+ this._root = document.createElement('div');
257
+ this._root.className = 'html-term';
258
+ this._root.tabIndex = 0;
259
+
260
+ // Scroll viewport — native scroll handles scrollback
261
+ this._scrollEl = document.createElement('div');
262
+ this._scrollEl.className = 'html-term-scroll';
263
+
264
+ // Spacer — its height represents scrollback content above the screen rows.
265
+ // Browser's native scrollbar uses this to provide a real scrollbar track.
266
+ this._spacer = document.createElement('div');
267
+ this._spacer.className = 'html-term-spacer';
268
+ this._spacer.style.cssText = 'width: 1px; height: 0; pointer-events: none;';
269
+
270
+ // Rows container — holds exactly this._rows div elements
271
+ this._rowsEl = document.createElement('div');
272
+ this._rowsEl.className = 'html-term-rows';
273
+
274
+ this._scrollEl.appendChild(this._spacer);
275
+ this._scrollEl.appendChild(this._rowsEl);
276
+
277
+ // Cursor element — positioned relative to _rowsEl
278
+ this._cursorEl = document.createElement('div');
279
+ this._cursorEl.className = 'html-term-cursor';
280
+ this._rowsEl.appendChild(this._cursorEl);
281
+
282
+ this._root.appendChild(this._scrollEl);
283
+
284
+ // Hidden textarea for keyboard input
285
+ this._input = document.createElement('textarea');
286
+ this._input.className = 'html-term-input';
287
+ this._input.autocomplete = 'off';
288
+ this._input.autocorrect = 'off';
289
+ this._input.autocapitalize = 'off';
290
+ this._input.spellcheck = false;
291
+ this._root.appendChild(this._input);
292
+
293
+ // Probe element for measuring cell size
294
+ this._probe = document.createElement('span');
295
+ this._probe.className = 'html-term-probe';
296
+ this._probe.style.cssText = `
297
+ position: absolute; visibility: hidden; white-space: pre;
298
+ font-family: ${this._fontFamily}; font-size: ${this._fontSize}px;
299
+ line-height: ${this._lineHeight};
300
+ `;
301
+ this._probe.textContent = 'W';
302
+ this._root.appendChild(this._probe);
303
+
304
+ this._container.appendChild(this._root);
305
+
306
+ // Row DOM elements (reused)
307
+ this._rowEls = [];
308
+ }
309
+
310
+ _measureCell() {
311
+ if (this._disposed) return;
312
+ const rect = this._probe.getBoundingClientRect();
313
+ if (rect.width > 0 && rect.height > 0) {
314
+ this._cellWidth = rect.width;
315
+ this._cellHeight = rect.height;
316
+ } else {
317
+ // Fallback estimate
318
+ this._cellWidth = this._fontSize * 0.6;
319
+ this._cellHeight = this._fontSize * this._lineHeight;
320
+ }
321
+ }
322
+
323
+ _calculateSize() {
324
+ const rect = this._scrollEl.getBoundingClientRect();
325
+ if (rect.width === 0 || rect.height === 0) return false;
326
+ const cs = getComputedStyle(this._scrollEl);
327
+ const w = rect.width - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
328
+ const h = rect.height - (parseFloat(cs.paddingTop) || 0) - (parseFloat(cs.paddingBottom) || 0);
329
+ const cols = Math.max(2, Math.floor(w / this._cellWidth));
330
+ const rows = Math.max(1, Math.floor(h / this._cellHeight));
331
+ if (cols !== this._cols || rows !== this._rows) {
332
+ this._cols = cols;
333
+ this._rows = rows;
334
+ return true;
335
+ }
336
+ return false;
337
+ }
338
+
339
+ _rebuildRows() {
340
+ // Remove existing rows (keep spacer and cursor)
341
+ for (const el of this._rowEls) el.remove();
342
+ this._rowEls = [];
343
+ this._rowHashes = [];
344
+ this._rowSpanPools = [];
345
+
346
+ // Insert row elements before the cursor
347
+ const frag = document.createDocumentFragment();
348
+ for (let r = 0; r < this._rows; r++) {
349
+ const el = document.createElement('div');
350
+ el.className = 'html-term-row';
351
+ el.style.height = `${this._cellHeight}px`;
352
+ el.style.lineHeight = `${this._cellHeight}px`;
353
+ frag.appendChild(el);
354
+ this._rowEls.push(el);
355
+ this._rowHashes.push('');
356
+ this._rowSpanPools.push([]);
357
+ }
358
+ this._rowsEl.insertBefore(frag, this._cursorEl);
359
+ this._lastFirstVisible = -1; // force full re-render
360
+ }
361
+
362
+ // ─── Scroll helpers ───────────────────────────────────
363
+
364
+ _updateSpacerHeight() {
365
+ if (!this._buffer || !this._cellHeight) return;
366
+ // Total virtual height: scrollback rows + screen rows.
367
+ // _rowsEl is position:absolute so doesn't contribute to scrollHeight.
368
+ // Math.round eliminates sub-pixel jitter that causes scrollbar flicker.
369
+ const h = Math.round((this._buffer.scrollbackLength + this._rows) * this._cellHeight);
370
+ this._spacer.style.height = h + 'px';
371
+ }
372
+
373
+ _isAtBottom() {
374
+ const el = this._scrollEl;
375
+ // Within 2px of bottom counts as "at bottom"
376
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 2;
377
+ }
378
+
379
+ _scrollToMax() {
380
+ this._suppressScrollEvent = true;
381
+ this._scrollEl.scrollTop = this._scrollEl.scrollHeight;
382
+ }
383
+
384
+ _getFirstVisibleRow() {
385
+ if (!this._cellHeight) return 0;
386
+ return Math.max(0, Math.floor(this._scrollEl.scrollTop / this._cellHeight));
387
+ }
388
+
389
+ // ─── Rendering ─────────────────────────────────────────
390
+
391
+ _scheduleRender() {
392
+ if (this._renderPending || this._disposed) return;
393
+ this._renderPending = true;
394
+ this._rafId = requestAnimationFrame(() => {
395
+ this._renderPending = false;
396
+ this._render();
397
+ });
398
+ }
399
+
400
+ _render() {
401
+ if (this._disposed || !this._buffer) return;
402
+
403
+ // ── Phase 1: Flush batched writes (all accumulated since last frame) ──
404
+ const hadWrites = this._flushWrites();
405
+
406
+ // Ensure correct row element count
407
+ while (this._rowEls.length < this._rows) {
408
+ const el = document.createElement('div');
409
+ el.className = 'html-term-row';
410
+ el.style.height = `${this._cellHeight}px`;
411
+ el.style.lineHeight = `${this._cellHeight}px`;
412
+ this._rowsEl.insertBefore(el, this._cursorEl);
413
+ this._rowEls.push(el);
414
+ this._rowHashes.push('');
415
+ this._rowSpanPools.push([]);
416
+ }
417
+ while (this._rowEls.length > this._rows) {
418
+ const el = this._rowEls.pop();
419
+ this._rowHashes.pop();
420
+ this._rowSpanPools.pop();
421
+ el.remove();
422
+ }
423
+
424
+ const scrollback = this._buffer.scrollbackLength;
425
+ let firstVisible;
426
+
427
+ if (this._isLive) {
428
+ // ── Live mode: sync spacer + pin scroll once per frame ──
429
+ // Write batching (Phase 1) ensures this runs at most once per rAF,
430
+ // not per write() chunk — eliminating N reflows/frame → 1.
431
+ if (this._spacerDirty) {
432
+ this._updateSpacerHeight();
433
+ this._spacerDirty = false;
434
+ }
435
+ if (!this._selecting) {
436
+ this._scrollToMax();
437
+ }
438
+ firstVisible = scrollback;
439
+ } else {
440
+ // ── Scrolled-up mode: sync spacer + use native scroll position ──
441
+ if (this._spacerDirty) {
442
+ this._updateSpacerHeight();
443
+ this._spacerDirty = false;
444
+ }
445
+
446
+ // Clamp firstVisible using calculated max instead of forcing reflow
447
+ const maxFirst = scrollback;
448
+ firstVisible = this._getFirstVisibleRow();
449
+ if (firstVisible > maxFirst) {
450
+ firstVisible = maxFirst;
451
+ this._suppressScrollEvent = true;
452
+ this._scrollEl.scrollTop = firstVisible * this._cellHeight;
453
+ }
454
+ if (!Number.isFinite(firstVisible) || firstVisible < 0) {
455
+ firstVisible = scrollback;
456
+ }
457
+ }
458
+
459
+ // Position _rowsEl at the current viewport via GPU-composited transform
460
+ const scrollTop = this._scrollEl.scrollTop;
461
+ this._rowsEl.style.transform = Number.isFinite(scrollTop) ? `translateY(${scrollTop}px)` : 'translateY(0)';
462
+
463
+ const scrolled = firstVisible !== this._lastFirstVisible;
464
+ this._lastFirstVisible = firstVisible;
465
+
466
+ // Determine which rows need re-rendering
467
+ const sel = this._normalizeSelection();
468
+ const dirty = this._buffer.getDirtyRows();
469
+ this._buffer.clearDirty();
470
+ const selDirty = this._selectionDirty;
471
+ this._selectionDirty = false;
472
+
473
+ const isLive = firstVisible >= scrollback;
474
+
475
+ if (scrolled || selDirty) {
476
+ this._renderAllVisible(firstVisible, scrollback, sel);
477
+ } else if ((dirty.size || dirty.length) > 0) {
478
+ if (isLive) {
479
+ for (const rowIdx of dirty) {
480
+ if (rowIdx >= 0 && rowIdx < this._rows) {
481
+ const row = this._buffer.getRow(rowIdx);
482
+ this._renderRowData(rowIdx, row, sel, scrollback + rowIdx, true);
483
+ }
484
+ }
485
+ } else {
486
+ this._renderAllVisible(firstVisible, scrollback, sel);
487
+ }
488
+ }
489
+
490
+ // Cursor: only visible when at bottom (live view)
491
+ if (isLive && this._buffer.cursorVisible) {
492
+ this._cursorEl.style.display = 'block';
493
+ const x = this._buffer.cursorX * this._cellWidth;
494
+ const y = this._buffer.cursorY * this._cellHeight;
495
+ this._cursorEl.style.transform = `translate(${x}px, ${y}px)`;
496
+ this._cursorEl.style.height = `${this._cellHeight}px`;
497
+ this._cursorEl.style.width = '2px';
498
+ } else {
499
+ this._cursorEl.style.display = 'none';
500
+ }
501
+ }
502
+
503
+ _renderAllVisible(firstVisible, scrollback, sel) {
504
+ for (let i = 0; i < this._rows; i++) {
505
+ const virtualRow = firstVisible + i;
506
+ let row;
507
+ if (virtualRow < scrollback) {
508
+ row = this._buffer.getScrollbackRow(virtualRow);
509
+ } else {
510
+ row = this._buffer.getRow(virtualRow - scrollback);
511
+ }
512
+ this._renderRowData(i, row, sel, virtualRow, false);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Render a single row to its DOM element using span pool diffing.
518
+ * Reuses existing span elements — updates className/style/textContent
519
+ * instead of rebuilding innerHTML. Falls back to innerHTML for rows
520
+ * with hyperlinks (data-url attribute).
521
+ *
522
+ * @param {number} screenIdx — screen row index (0-based)
523
+ * @param {Array} row — cell array from buffer
524
+ * @param {object|null} sel — normalized selection bounds
525
+ * @param {number} absoluteRow — absolute row index (scrollback + screen)
526
+ * @param {boolean} forceDirty — true if row is known-dirty (skip hash check)
527
+ */
528
+ _renderRowData(screenIdx, row, sel, absoluteRow, forceDirty) {
529
+ const el = this._rowEls[screenIdx];
530
+ if (!el) return;
531
+ if (!row) {
532
+ el.innerHTML = '';
533
+ this._rowHashes[screenIdx] = '';
534
+ this._rowSpanPools[screenIdx] = [];
535
+ return;
536
+ }
537
+
538
+ const cols = this._cols;
539
+ const hasSel = sel !== null;
540
+
541
+ // Build span groups: [ { cls, style, text, url? }, ... ]
542
+ const groups = [];
543
+ let prevCls = '\0'; // sentinel — never matches first cell
544
+ let prevStyle = '\0';
545
+ let hasUrl = false;
546
+
547
+ for (let c = 0; c < cols; c++) {
548
+ const cell = row[c];
549
+ if (!cell) continue;
550
+
551
+ const inSel = hasSel && this._cellInSelection(absoluteRow, c, sel);
552
+
553
+ let cls = '';
554
+ let style = '';
555
+ let fg = cell.fg;
556
+ let bg = cell.bg;
557
+
558
+ if (cell.inverse) {
559
+ const tmp = fg; fg = bg; bg = tmp;
560
+ if (fg === null) fg = THEME.bg;
561
+ if (bg === null) bg = THEME.fg;
562
+ }
563
+
564
+ if (inSel) {
565
+ style = `background:${THEME.selBg};color:${THEME.selFg}`;
566
+ } else {
567
+ if (fg !== null) {
568
+ if (typeof fg === 'number' && fg >= 0 && fg <= 15) {
569
+ cls = FG_CLASSES[fg];
570
+ } else if (typeof fg === 'string') {
571
+ style = `color:${fg}`;
572
+ }
573
+ }
574
+ if (bg !== null) {
575
+ if (typeof bg === 'number' && bg >= 0 && bg <= 15) {
576
+ cls = cls ? cls + ' ' + BG_CLASSES[bg] : BG_CLASSES[bg];
577
+ } else if (typeof bg === 'string') {
578
+ style = style ? style + `;background:${bg}` : `background:${bg}`;
579
+ }
580
+ }
581
+ }
582
+
583
+ if (cell.bold) cls = cls ? cls + ' ht-b' : 'ht-b';
584
+ if (cell.dim) cls = cls ? cls + ' ht-dim' : 'ht-dim';
585
+ if (cell.italic) cls = cls ? cls + ' ht-i' : 'ht-i';
586
+ if (cell.underline) cls = cls ? cls + ' ht-u' : 'ht-u';
587
+ if (cell.strikethrough) cls = cls ? cls + ' ht-s' : 'ht-s';
588
+ if (cell.url) { cls = cls ? cls + ' ht-link' : 'ht-link'; hasUrl = true; }
589
+
590
+ if (cls !== prevCls || style !== prevStyle) {
591
+ groups.push({ cls, style, text: cell.char, url: cell.url || null });
592
+ prevCls = cls;
593
+ prevStyle = style;
594
+ } else {
595
+ groups[groups.length - 1].text += cell.char;
596
+ }
597
+ }
598
+
599
+ // Build a lightweight hash for skip detection (concatenate group signatures)
600
+ let hash = '';
601
+ for (let g = 0; g < groups.length; g++) {
602
+ const gr = groups[g];
603
+ hash += gr.cls + '|' + gr.style + '|' + gr.text + '\n';
604
+ }
605
+
606
+ // Hash check: skip DOM update if content unchanged
607
+ if (!forceDirty && this._rowHashes[screenIdx] === hash) return;
608
+ this._rowHashes[screenIdx] = hash;
609
+
610
+ // If row has hyperlinks, fall back to innerHTML (need data-url attributes)
611
+ if (hasUrl) {
612
+ let html = '';
613
+ for (let g = 0; g < groups.length; g++) {
614
+ const gr = groups[g];
615
+ if (gr.cls || gr.style) {
616
+ html += '<span';
617
+ if (gr.cls) html += ` class="${gr.cls}"`;
618
+ if (gr.style) html += ` style="${gr.style}"`;
619
+ if (gr.url) html += ` data-url="${this._escAttr(gr.url)}"`;
620
+ html += '>';
621
+ // textContent-safe chars need entity escaping in innerHTML
622
+ const t = gr.text;
623
+ for (let i = 0; i < t.length; i++) html += HTML_ENTITIES[t[i]] || t[i];
624
+ html += '</span>';
625
+ } else {
626
+ const t = gr.text;
627
+ for (let i = 0; i < t.length; i++) html += HTML_ENTITIES[t[i]] || t[i];
628
+ }
629
+ }
630
+ el.innerHTML = html;
631
+ this._rowSpanPools[screenIdx] = [];
632
+ return;
633
+ }
634
+
635
+ // DOM diffing: reuse/update existing spans
636
+ const pool = this._rowSpanPools[screenIdx];
637
+ let spanIdx = 0;
638
+
639
+ for (let g = 0; g < groups.length; g++) {
640
+ const gr = groups[g];
641
+ // Groups with no styling → bare text node (merge into a single span for simplicity)
642
+ let span;
643
+ if (spanIdx < pool.length) {
644
+ span = pool[spanIdx];
645
+ } else {
646
+ span = document.createElement('span');
647
+ el.appendChild(span);
648
+ pool.push(span);
649
+ }
650
+
651
+ // Update only if changed
652
+ if (span.className !== gr.cls) span.className = gr.cls;
653
+ const css = gr.style || '';
654
+ if (span.style.cssText !== css) span.style.cssText = css;
655
+ if (span.textContent !== gr.text) span.textContent = gr.text;
656
+ spanIdx++;
657
+ }
658
+
659
+ // Remove excess spans from previous render
660
+ while (pool.length > spanIdx) {
661
+ const old = pool.pop();
662
+ old.remove();
663
+ }
664
+ }
665
+
666
+ // ─── Keyboard input ───────────────────────────────────
667
+
668
+ _wireKeyboard() {
669
+ // Focus proxy: click on terminal → focus textarea (but not during selection drag)
670
+ this._root.addEventListener('mousedown', (e) => {
671
+ if (e.target === this._scrollEl || e.target === this._rowsEl ||
672
+ e.target === this._root || e.target.classList.contains('html-term-row') ||
673
+ e.target.closest('.html-term-rows')) {
674
+ // Delay focus to avoid stealing it during selection drag.
675
+ // onUp handler calls focus() when selection is complete.
676
+ if (!this._selecting) {
677
+ setTimeout(() => { if (!this._selecting) this._input.focus(); }, 0);
678
+ }
679
+ }
680
+ });
681
+
682
+ this._input.addEventListener('keydown', (e) => {
683
+ if (this._disposed) return;
684
+ const seq = this._keyToSequence(e);
685
+ if (seq !== null) {
686
+ e.preventDefault();
687
+ // Any keyboard input → snap to live mode
688
+ if (!this._isLive) {
689
+ this._isLive = true;
690
+ this._scrollToMax();
691
+ this._scheduleRender();
692
+ }
693
+ this._send(seq);
694
+ }
695
+ });
696
+
697
+ // Handle paste (text + images)
698
+ this._input.addEventListener('paste', (e) => {
699
+ if (this._disposed) return;
700
+ e.preventDefault();
701
+ if (e.clipboardData) {
702
+ // Check for image paste first
703
+ for (const item of e.clipboardData.items) {
704
+ if (item.type.startsWith('image/') && this._ws?.readyState === WebSocket.OPEN) {
705
+ const blob = item.getAsFile();
706
+ if (!blob) return;
707
+ const reader = new FileReader();
708
+ reader.onload = () => {
709
+ const base64 = reader.result.split(',')[1];
710
+ this._ws.send(JSON.stringify({ type: 'image_paste', data: base64, mimeType: item.type }));
711
+ };
712
+ reader.readAsDataURL(blob);
713
+ return;
714
+ }
715
+ }
716
+ }
717
+ // Text paste
718
+ const text = e.clipboardData?.getData('text') || '';
719
+ if (!text) return;
720
+ const normalized = text.replace(/\r?\n/g, '\r');
721
+ if (this._buffer.bracketedPaste) {
722
+ this._send('\x1b[200~' + normalized + '\x1b[201~');
723
+ } else {
724
+ this._send(normalized);
725
+ }
726
+ });
727
+
728
+ // Composition input (IME)
729
+ this._input.addEventListener('input', (e) => {
730
+ if (this._disposed) return;
731
+ if (e.inputType === 'insertText' && this._input.value) {
732
+ this._send(this._input.value);
733
+ this._input.value = '';
734
+ }
735
+ });
736
+ }
737
+
738
+ _keyToSequence(e) {
739
+ const key = e.key;
740
+ const ctrl = e.ctrlKey;
741
+ const alt = e.altKey;
742
+ const shift = e.shiftKey;
743
+ const meta = e.metaKey;
744
+
745
+ // Ctrl+Shift+C → copy
746
+ if (ctrl && shift && key === 'C') {
747
+ const sel = this.getSelection();
748
+ if (sel) {
749
+ navigator.clipboard.writeText(sel).catch(() => {});
750
+ return null;
751
+ }
752
+ }
753
+
754
+ // Ctrl+V / Ctrl+Shift+V → let browser fire paste event
755
+ if (ctrl && (key === 'v' || key === 'V')) return null;
756
+
757
+ // Ctrl+C → SIGINT if no selection
758
+ if (ctrl && !shift && key === 'c') {
759
+ const sel = window.getSelection();
760
+ if (sel && sel.toString().length > 0) return null;
761
+ return '\x03';
762
+ }
763
+
764
+ // Ctrl+letter → control codes
765
+ if (ctrl && !alt && !meta && key.length === 1) {
766
+ const code = key.toUpperCase().charCodeAt(0);
767
+ if (code >= 65 && code <= 90) return String.fromCharCode(code - 64);
768
+ if (key === '[') return '\x1b';
769
+ if (key === '\\') return '\x1c';
770
+ if (key === ']') return '\x1d';
771
+ if (key === '^') return '\x1e';
772
+ if (key === '_') return '\x1f';
773
+ }
774
+
775
+ // Alt+letter → ESC + letter
776
+ if (alt && !ctrl && !meta && key.length === 1) return '\x1b' + key;
777
+
778
+ // Application cursor mode
779
+ const app = this._buffer?.appCursorKeys;
780
+ const CSI = app ? '\x1bO' : '\x1b[';
781
+
782
+ switch (key) {
783
+ case 'Enter': return shift ? '\x16\n' : '\r';
784
+ case 'Backspace': return ctrl ? '\x08' : '\x7f';
785
+ case 'Tab': return shift ? '\x1b[Z' : '\t';
786
+ case 'Escape': return '\x1b';
787
+ case 'ArrowUp': return shift ? '\x1b[1;2A' : (alt ? '\x1b[1;3A' : (ctrl ? '\x1b[1;5A' : `${CSI}A`));
788
+ case 'ArrowDown': return shift ? '\x1b[1;2B' : (alt ? '\x1b[1;3B' : (ctrl ? '\x1b[1;5B' : `${CSI}B`));
789
+ case 'ArrowRight':return shift ? '\x1b[1;2C' : (alt ? '\x1b[1;3C' : (ctrl ? '\x1b[1;5C' : `${CSI}C`));
790
+ case 'ArrowLeft': return shift ? '\x1b[1;2D' : (alt ? '\x1b[1;3D' : (ctrl ? '\x1b[1;5D' : `${CSI}D`));
791
+ case 'Home': return ctrl ? '\x1b[1;5H' : '\x1b[H';
792
+ case 'End': return ctrl ? '\x1b[1;5F' : '\x1b[F';
793
+ case 'PageUp': return '\x1b[5~';
794
+ case 'PageDown': return '\x1b[6~';
795
+ case 'Insert': return '\x1b[2~';
796
+ case 'Delete': return '\x1b[3~';
797
+ case 'F1': return '\x1bOP';
798
+ case 'F2': return '\x1bOQ';
799
+ case 'F3': return '\x1bOR';
800
+ case 'F4': return '\x1bOS';
801
+ case 'F5': return '\x1b[15~';
802
+ case 'F6': return '\x1b[17~';
803
+ case 'F7': return '\x1b[18~';
804
+ case 'F8': return '\x1b[19~';
805
+ case 'F9': return '\x1b[20~';
806
+ case 'F10': return '\x1b[21~';
807
+ case 'F11': return '\x1b[23~';
808
+ case 'F12': return '\x1b[24~';
809
+ }
810
+
811
+ // Regular printable character
812
+ if (key.length === 1 && !ctrl && !alt && !meta) return key;
813
+
814
+ return null;
815
+ }
816
+
817
+ paste(text) {
818
+ if (!text) return;
819
+ const normalized = text.replace(/\r?\n/g, '\r');
820
+ if (this._buffer.bracketedPaste) {
821
+ this._send('\x1b[200~' + normalized + '\x1b[201~');
822
+ } else {
823
+ this._send(normalized);
824
+ }
825
+ }
826
+
827
+ _send(data) {
828
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
829
+ this._ws.send(JSON.stringify({ type: 'input', data }));
830
+ }
831
+ }
832
+
833
+ // ─── Mouse / selection ────────────────────────────────
834
+
835
+ _wireMouse() {
836
+ this._scrollEl.addEventListener('mousedown', (e) => {
837
+ if (e.button !== 0) return;
838
+ this._selecting = true;
839
+ // Clear previous selection first, THEN set new start
840
+ this._selStart = null;
841
+ this._selEnd = null;
842
+ this._selectionDirty = true;
843
+ this._scheduleRender();
844
+ this._selStart = this._mouseToCell(e);
845
+
846
+ const onMove = (ev) => {
847
+ if (!this._selecting) return;
848
+ this._selEnd = this._mouseToCell(ev);
849
+ this._selectionDirty = true;
850
+ this._scheduleRender();
851
+
852
+ // Auto-scroll when dragging near edges
853
+ this._lastMouseX = ev.clientX;
854
+ this._lastMouseY = ev.clientY;
855
+ const rect = this._scrollEl.getBoundingClientRect();
856
+ const edgeZone = 40;
857
+ if (ev.clientY < rect.top + edgeZone) {
858
+ const proximity = Math.max(0, rect.top + edgeZone - ev.clientY);
859
+ this._startAutoScroll(-Math.ceil(proximity / 8));
860
+ } else if (ev.clientY > rect.bottom - edgeZone) {
861
+ const proximity = Math.max(0, ev.clientY - (rect.bottom - edgeZone));
862
+ this._startAutoScroll(Math.ceil(proximity / 8));
863
+ } else {
864
+ this._stopAutoScroll();
865
+ }
866
+ };
867
+
868
+ const onUp = () => {
869
+ this._stopAutoScroll();
870
+ this._selecting = false;
871
+ document.removeEventListener('mousemove', onMove);
872
+ document.removeEventListener('mouseup', onUp);
873
+ if (!this._selStart || !this._selEnd || (this._selStart.row === this._selEnd.row && this._selStart.col === this._selEnd.col)) {
874
+ this._selStart = null;
875
+ this._selEnd = null;
876
+ } else {
877
+ // Copy-on-select
878
+ const sel = this.getSelection();
879
+ if (sel) navigator.clipboard.writeText(sel).catch(() => {});
880
+ }
881
+ this._selectionDirty = true;
882
+ this._scheduleRender();
883
+ this._input.focus();
884
+ window.getSelection()?.removeAllRanges();
885
+ };
886
+
887
+ document.addEventListener('mousemove', onMove);
888
+ document.addEventListener('mouseup', onUp);
889
+ });
890
+
891
+ // Double-click to select word
892
+ this._scrollEl.addEventListener('dblclick', (e) => {
893
+ e.preventDefault(); // Block native word selection
894
+ const cell = this._mouseToCell(e);
895
+ const scrollback = this._buffer.scrollbackLength;
896
+ const row = cell.row < scrollback
897
+ ? this._buffer.getScrollbackRow(cell.row)
898
+ : this._buffer.getRow(cell.row - scrollback);
899
+ if (!row) return;
900
+ let start = cell.col;
901
+ let end = cell.col;
902
+ while (start > 0 && row[start - 1]?.char !== ' ') start--;
903
+ while (end < this._cols - 1 && row[end + 1]?.char !== ' ') end++;
904
+ this._selStart = { row: cell.row, col: start };
905
+ this._selEnd = { row: cell.row, col: end };
906
+ this._selectionDirty = true;
907
+ this._scheduleRender();
908
+ // Copy-on-select
909
+ const sel = this.getSelection();
910
+ if (sel) navigator.clipboard.writeText(sel).catch(() => {});
911
+ });
912
+ }
913
+
914
+ _mouseToCell(e) {
915
+ const rect = this._rowsEl.getBoundingClientRect();
916
+ const x = e.clientX - rect.left;
917
+ const y = e.clientY - rect.top;
918
+ const screenRow = Math.min(Math.max(0, Math.floor(y / this._cellHeight)), this._rows - 1);
919
+ const firstVisible = this._getFirstVisibleRow();
920
+ return {
921
+ row: firstVisible + screenRow, // absolute index into scrollback+screen
922
+ col: Math.min(Math.max(0, Math.floor(x / this._cellWidth)), this._cols - 1),
923
+ };
924
+ }
925
+
926
+ _normalizeSelection() {
927
+ if (!this._selStart || !this._selEnd) return null;
928
+ let { row: r1, col: c1 } = this._selStart;
929
+ let { row: r2, col: c2 } = this._selEnd;
930
+ if (r1 > r2 || (r1 === r2 && c1 > c2)) {
931
+ [r1, c1, r2, c2] = [r2, c2, r1, c1];
932
+ }
933
+ return { startRow: r1, startCol: c1, endRow: r2, endCol: c2 };
934
+ }
935
+
936
+ _cellInSelection(row, col, sel) {
937
+ if (row < sel.startRow || row > sel.endRow) return false;
938
+ if (row === sel.startRow && col < sel.startCol) return false;
939
+ if (row === sel.endRow && col > sel.endCol) return false;
940
+ return true;
941
+ }
942
+
943
+ _clearSelectionHighlight() {
944
+ if (this._selStart || this._selEnd) this._selectionDirty = true;
945
+ this._selStart = null;
946
+ this._selEnd = null;
947
+ }
948
+
949
+ _startAutoScroll(speed) {
950
+ this._autoScrollSpeed = speed;
951
+ if (this._autoScrollRAF) return; // already running
952
+ const tick = () => {
953
+ if (!this._autoScrollSpeed) { this._autoScrollRAF = null; return; }
954
+ this._suppressScrollEvent = true;
955
+ this._scrollEl.scrollTop += this._autoScrollSpeed;
956
+ this._isLive = this._isAtBottom();
957
+ // Update selection endpoint at current mouse position
958
+ if (this._selecting) {
959
+ const synth = { clientX: this._lastMouseX, clientY: this._lastMouseY };
960
+ this._selEnd = this._mouseToCell(synth);
961
+ this._selectionDirty = true;
962
+ }
963
+ this._scheduleRender();
964
+ this._autoScrollRAF = requestAnimationFrame(tick);
965
+ };
966
+ this._autoScrollRAF = requestAnimationFrame(tick);
967
+ }
968
+
969
+ _stopAutoScroll() {
970
+ this._autoScrollSpeed = 0;
971
+ if (this._autoScrollRAF) {
972
+ cancelAnimationFrame(this._autoScrollRAF);
973
+ this._autoScrollRAF = null;
974
+ }
975
+ }
976
+
977
+ // ─── Scroll + Resize ──────────────────────────────────
978
+
979
+ _onScroll() {
980
+ if (this._suppressScrollEvent) {
981
+ this._suppressScrollEvent = false;
982
+ return;
983
+ }
984
+ const wasLive = this._isLive;
985
+ this._isLive = this._isAtBottom();
986
+
987
+ // Transitioning from live → scrolled-up: sync spacer height now so
988
+ // native scroll positioning works correctly with current scrollback.
989
+ if (wasLive && !this._isLive && this._spacerDirty) {
990
+ this._updateSpacerHeight();
991
+ this._spacerDirty = false;
992
+ }
993
+
994
+ this._scheduleRender();
995
+ }
996
+
997
+ _wireScroll() {
998
+ // Passive listener: doesn't block compositor thread during scroll.
999
+ // This is critical for smooth 60fps scrolling — non-passive listeners
1000
+ // force the browser to wait for JS before compositing the scroll frame.
1001
+ this._scrollEl.addEventListener('scroll', this._boundScrollHandler, { passive: true });
1002
+ }
1003
+
1004
+ _wireResize() {
1005
+ this._ro = new ResizeObserver(() => {
1006
+ if (this._disposed) return;
1007
+ // Debounce resize to prevent layout thrashing during continuous drag
1008
+ if (this._resizeTimer) clearTimeout(this._resizeTimer);
1009
+ this._resizeTimer = setTimeout(() => {
1010
+ this._resizeTimer = null;
1011
+ if (!this._disposed) this.fit();
1012
+ }, RESIZE_DEBOUNCE_MS);
1013
+ });
1014
+ this._ro.observe(this._scrollEl);
1015
+ }
1016
+
1017
+ // ─── HTML helpers ─────────────────────────────────────
1018
+
1019
+ _escAttr(str) {
1020
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1021
+ }
1022
+ }