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.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- 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 = { '&': '&', '<': '<', '>': '>' };
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
1021
|
+
}
|
|
1022
|
+
}
|