@bakapiano/ccsm 0.22.2 → 0.22.4

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 (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +233 -231
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +187 -15
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +148 -14
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
@@ -3,6 +3,7 @@
3
3
  // resize propagation, paste handling, and browser/mobile lifecycle hooks.
4
4
 
5
5
  import { wsBase, getToken, getDeviceId } from '../backend.js';
6
+ import { TerminalResizeDebouncer } from './TerminalResizeDebouncer.js';
6
7
  import { XtermTerminal } from './XtermTerminal.js';
7
8
 
8
9
  export class TerminalInstance {
@@ -17,18 +18,37 @@ export class TerminalInstance {
17
18
  this.reconnectTimer = null;
18
19
  this.attempts = 0;
19
20
  this.everOpened = false;
21
+ this.inReplay = false;
22
+ this.replayDepth = 0;
23
+ this.isVisible = false;
24
+ this.lastLayoutDimensions = null;
25
+ this.lastSentDimensions = null;
26
+ this.pendingLayoutFrame = null;
27
+ this.layoutRetryTimers = new Set();
20
28
  this.disposables = [];
21
29
  this.helperTextarea = null;
30
+ this.resizeDebouncer = new TerminalResizeDebouncer({
31
+ isVisible: () => this.isVisible,
32
+ getXterm: () => this.xterm,
33
+ resizeBoth: (cols, rows) => this._applyResize(cols, rows),
34
+ resizeX: (cols) => this._applyResize(cols, this.xterm.rows),
35
+ resizeY: (rows) => this._applyResize(this.xterm.cols, rows),
36
+ });
37
+ const refreshDisposable = this.xterm.onDidRequestRefreshDimensions(() => {
38
+ this.scheduleLayout({ immediate: this.isVisible, retries: true });
39
+ });
40
+ this.disposables.push(() => refreshDisposable.dispose());
22
41
  }
23
42
 
24
43
  attachToElement(host) {
25
44
  this.host = host;
26
45
  this.xterm.attachToElement(host);
27
46
  this._registerColorOscHandlers();
28
- this._connect();
29
47
  this._wireXtermEvents();
30
48
  this._wireDomLifecycle();
31
- this.xterm.focus();
49
+ this.setVisible(this._isHostVisible());
50
+ this._connect();
51
+ if (this.isVisible) this.xterm.focus();
32
52
  }
33
53
 
34
54
  sendInput(data) {
@@ -43,9 +63,60 @@ export class TerminalInstance {
43
63
  this.xterm.applyResolvedTheme();
44
64
  }
45
65
 
66
+ layout(width, height, immediate = false) {
67
+ const layoutDimensions = this._resolveLayoutDimensions(width, height);
68
+ if (!layoutDimensions) return null;
69
+
70
+ this.lastLayoutDimensions = layoutDimensions;
71
+ const proposed = this.xterm.proposeDimensions(layoutDimensions.width, layoutDimensions.height);
72
+ if (!proposed) return null;
73
+
74
+ this.resizeDebouncer.resize(proposed.cols, proposed.rows, immediate);
75
+ return proposed;
76
+ }
77
+
78
+ scheduleLayout(options = {}) {
79
+ const { immediate = false, retries = false, forceRedraw = false } =
80
+ typeof options === 'boolean' ? { immediate: options } : options;
81
+ if (this.closedByUs) return null;
82
+
83
+ if (immediate) {
84
+ this._cancelScheduledLayout();
85
+ const result = this.layout(undefined, undefined, true);
86
+ if (forceRedraw) this.xterm.forceRedraw();
87
+ if (retries) this._scheduleLayoutRetries(forceRedraw);
88
+ return result;
89
+ }
90
+
91
+ if (this.pendingLayoutFrame === null) {
92
+ this.pendingLayoutFrame = requestAnimationFrame(() => {
93
+ this.pendingLayoutFrame = null;
94
+ this.layout();
95
+ if (forceRedraw) this.xterm.forceRedraw();
96
+ });
97
+ }
98
+ if (retries) this._scheduleLayoutRetries(forceRedraw);
99
+ return null;
100
+ }
101
+
102
+ setVisible(visible) {
103
+ const nextVisible = !!visible;
104
+ const didChange = this.isVisible !== nextVisible;
105
+ this.isVisible = nextVisible;
106
+ this.host?.classList.toggle('active', nextVisible);
107
+
108
+ if (nextVisible) {
109
+ this.resizeDebouncer.flush();
110
+ this.scheduleLayout({ immediate: true, retries: true, forceRedraw: true });
111
+ }
112
+ return didChange;
113
+ }
114
+
46
115
  dispose() {
47
116
  this.closedByUs = true;
48
117
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
118
+ this._cancelScheduledLayout();
119
+ this.resizeDebouncer.dispose();
49
120
  for (const dispose of this.disposables.splice(0)) {
50
121
  try { dispose(); } catch {}
51
122
  }
@@ -66,14 +137,14 @@ export class TerminalInstance {
66
137
  }
67
138
  this.everOpened = true;
68
139
  this.attempts = 0;
69
- this.xterm.scheduleFit();
70
- this._sendFrame({ type: 'resize', cols: this.xterm.cols, rows: this.xterm.rows });
140
+ this.scheduleLayout({ immediate: true, retries: true });
141
+ this._sendResize(this.xterm.cols, this.xterm.rows, true);
71
142
  };
72
143
  ws.onmessage = (ev) => {
73
144
  let frame;
74
145
  try { frame = JSON.parse(ev.data); } catch { return; }
75
146
  if (frame.type === 'output') {
76
- this.xterm.write(frame.data);
147
+ this._writeProcessData(frame.data, !!frame.replay);
77
148
  } else if (frame.type === 'exit') {
78
149
  this.xterm.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
79
150
  }
@@ -101,10 +172,11 @@ export class TerminalInstance {
101
172
 
102
173
  _wireXtermEvents() {
103
174
  const dataDisposable = this.xterm.onData((data) => {
175
+ if (this.inReplay) return;
104
176
  this._sendFrame({ type: 'input', data });
105
177
  });
106
178
  const resizeDisposable = this.xterm.onResize(({ cols, rows }) => {
107
- this._sendFrame({ type: 'resize', cols, rows });
179
+ this._sendResize(cols, rows);
108
180
  });
109
181
  this.disposables.push(
110
182
  () => dataDisposable.dispose(),
@@ -114,12 +186,19 @@ export class TerminalInstance {
114
186
 
115
187
  _wireDomLifecycle() {
116
188
  const host = this.host;
117
- const ro = new ResizeObserver(() => this.xterm.fit());
189
+ const ro = new ResizeObserver((entries) => {
190
+ const box = entries[0]?.contentRect;
191
+ if (box) {
192
+ this.layout(box.width, box.height);
193
+ } else {
194
+ this.layout();
195
+ }
196
+ });
118
197
  ro.observe(host);
119
198
  this.disposables.push(() => ro.disconnect());
120
199
 
121
200
  const vv = window.visualViewport;
122
- const onVisualResize = () => this.xterm.scheduleFit();
201
+ const onVisualResize = () => this.scheduleLayout({ retries: true });
123
202
  vv?.addEventListener?.('resize', onVisualResize);
124
203
  vv?.addEventListener?.('scroll', onVisualResize);
125
204
  this.disposables.push(() => {
@@ -134,6 +213,7 @@ export class TerminalInstance {
134
213
  }
135
214
 
136
215
  this._wireTabVisibilityRefresh(host);
216
+ this._wireDocumentVisibilityRefresh();
137
217
  this._wirePasteHandlers(host);
138
218
  this._wireModifiedEnterHandler(host);
139
219
  this._wireCompositionHandlers();
@@ -143,18 +223,24 @@ export class TerminalInstance {
143
223
  const panel = host.closest('.tab-panel');
144
224
  if (!panel) return;
145
225
  const panelMo = new MutationObserver(() => {
146
- if (panel.hasAttribute('data-active')) {
147
- requestAnimationFrame(() => {
148
- this.xterm.clearTextureAtlas();
149
- this.xterm.scheduleFit();
150
- this.xterm.refresh();
151
- });
152
- }
226
+ this.setVisible(this._isHostVisible());
153
227
  });
154
228
  panelMo.observe(panel, { attributes: true, attributeFilter: ['data-active'] });
155
229
  this.disposables.push(() => panelMo.disconnect());
156
230
  }
157
231
 
232
+ _wireDocumentVisibilityRefresh() {
233
+ const onVisibilityChange = () => {
234
+ this.setVisible(!document.hidden && this._isHostVisible());
235
+ };
236
+ document.addEventListener('visibilitychange', onVisibilityChange);
237
+ window.addEventListener('focus', onVisibilityChange);
238
+ this.disposables.push(
239
+ () => document.removeEventListener('visibilitychange', onVisibilityChange),
240
+ () => window.removeEventListener('focus', onVisibilityChange),
241
+ );
242
+ }
243
+
158
244
  _wirePasteHandlers(host) {
159
245
  const isOurs = () => {
160
246
  const ae = document.activeElement;
@@ -235,6 +321,7 @@ export class TerminalInstance {
235
321
  _registerColorOscHandlers() {
236
322
  const answerColorOsc = (code, getHex) => (data) => {
237
323
  if (data !== '?') return false;
324
+ if (this.inReplay) return true;
238
325
  const hex = getHex();
239
326
  const ch = (i) => parseInt(hex.slice(i, i + 2), 16);
240
327
  const w = (v) => (v * 257).toString(16).padStart(4, '0');
@@ -254,6 +341,91 @@ export class TerminalInstance {
254
341
  }
255
342
  }
256
343
 
344
+ _sendResize(cols, rows, force = false) {
345
+ if (!(cols > 0 && rows > 0)) return;
346
+ if (!force
347
+ && this.lastSentDimensions
348
+ && this.lastSentDimensions.cols === cols
349
+ && this.lastSentDimensions.rows === rows) {
350
+ return;
351
+ }
352
+ this.lastSentDimensions = { cols, rows };
353
+ this._sendFrame({ type: 'resize', cols, rows });
354
+ }
355
+
356
+ _writeProcessData(data, replay) {
357
+ if (!replay) {
358
+ this.xterm.write(data);
359
+ return;
360
+ }
361
+ this._beginReplay();
362
+ this.xterm.write(data, () => {
363
+ this._endReplay();
364
+ });
365
+ }
366
+
367
+ _beginReplay() {
368
+ this.replayDepth++;
369
+ this.inReplay = true;
370
+ }
371
+
372
+ _endReplay() {
373
+ this.replayDepth = Math.max(0, this.replayDepth - 1);
374
+ this.inReplay = this.replayDepth > 0;
375
+ }
376
+
377
+ _applyResize(cols, rows) {
378
+ if (this.closedByUs) return;
379
+ if (!(cols > 0 && rows > 0)) return;
380
+ this.xterm.resize(cols, rows);
381
+ this._sendResize(this.xterm.cols, this.xterm.rows);
382
+ }
383
+
384
+ _resolveLayoutDimensions(width, height) {
385
+ if (width > 0 && height > 0) {
386
+ return { width, height };
387
+ }
388
+ if (!this.host) return null;
389
+ const rect = this.host.getBoundingClientRect();
390
+ const resolvedWidth = rect.width || this.host.clientWidth;
391
+ const resolvedHeight = rect.height || this.host.clientHeight;
392
+ if (!(resolvedWidth > 0 && resolvedHeight > 0)) return null;
393
+ return { width: resolvedWidth, height: resolvedHeight };
394
+ }
395
+
396
+ _scheduleLayoutRetries(forceRedraw = false) {
397
+ this._clearLayoutRetryTimers();
398
+ for (const delay of [60, 200]) {
399
+ const timer = setTimeout(() => {
400
+ this.layoutRetryTimers.delete(timer);
401
+ this.layout(undefined, undefined, true);
402
+ if (forceRedraw) this.xterm.forceRedraw();
403
+ }, delay);
404
+ this.layoutRetryTimers.add(timer);
405
+ }
406
+ }
407
+
408
+ _cancelScheduledLayout() {
409
+ if (this.pendingLayoutFrame !== null) {
410
+ cancelAnimationFrame(this.pendingLayoutFrame);
411
+ this.pendingLayoutFrame = null;
412
+ }
413
+ this._clearLayoutRetryTimers();
414
+ }
415
+
416
+ _clearLayoutRetryTimers() {
417
+ for (const timer of this.layoutRetryTimers) clearTimeout(timer);
418
+ this.layoutRetryTimers.clear();
419
+ }
420
+
421
+ _isHostVisible() {
422
+ if (!this.host || !this.host.isConnected || document.hidden) return false;
423
+ const panel = this.host.closest('.tab-panel');
424
+ if (panel && !panel.hasAttribute('data-active')) return false;
425
+ const style = window.getComputedStyle(this.host);
426
+ return style.display !== 'none' && style.visibility !== 'hidden';
427
+ }
428
+
257
429
  _wsUrl() {
258
430
  const tok = getToken();
259
431
  const dev = getDeviceId();
@@ -0,0 +1,126 @@
1
+ const START_DEBOUNCING_THRESHOLD = 200;
2
+ const DEBOUNCE_RESIZE_X_DELAY = 100;
3
+
4
+ export class TerminalResizeDebouncer {
5
+ constructor({ isVisible, getXterm, resizeBoth, resizeX, resizeY }) {
6
+ this.isVisible = isVisible;
7
+ this.getXterm = getXterm;
8
+ this.resizeBoth = resizeBoth;
9
+ this.resizeX = resizeX;
10
+ this.resizeY = resizeY;
11
+ this.latestX = 0;
12
+ this.latestY = 0;
13
+ this.resizeXTimer = null;
14
+ this.resizeXIdle = null;
15
+ this.resizeYIdle = null;
16
+ this.disposed = false;
17
+ }
18
+
19
+ resize(cols, rows, immediate = false) {
20
+ if (this.disposed) return;
21
+ this.latestX = cols;
22
+ this.latestY = rows;
23
+
24
+ const xterm = this.getXterm();
25
+ const normalBufferLength = xterm?.normalBufferLength ?? 0;
26
+ if (immediate || normalBufferLength < START_DEBOUNCING_THRESHOLD) {
27
+ this._clearPending();
28
+ this.resizeBoth(cols, rows);
29
+ return;
30
+ }
31
+
32
+ if (!this.isVisible()) {
33
+ this._scheduleIdleResizeX();
34
+ this._scheduleIdleResizeY();
35
+ return;
36
+ }
37
+
38
+ this._cancelIdleResizeY();
39
+ this.resizeY(rows);
40
+ this._scheduleDebouncedResizeX();
41
+ }
42
+
43
+ flush() {
44
+ if (this.disposed) return;
45
+ if (!this._hasPending()) return;
46
+ this._clearPending();
47
+ this.resizeBoth(this.latestX, this.latestY);
48
+ }
49
+
50
+ dispose() {
51
+ this.disposed = true;
52
+ this._clearPending();
53
+ }
54
+
55
+ _hasPending() {
56
+ return this.resizeXTimer !== null || this.resizeXIdle !== null || this.resizeYIdle !== null;
57
+ }
58
+
59
+ _clearPending() {
60
+ this._cancelDebouncedResizeX();
61
+ this._cancelIdleResizeX();
62
+ this._cancelIdleResizeY();
63
+ }
64
+
65
+ _scheduleDebouncedResizeX() {
66
+ this._cancelIdleResizeX();
67
+ if (this.resizeXTimer !== null) clearTimeout(this.resizeXTimer);
68
+ this.resizeXTimer = setTimeout(() => {
69
+ this.resizeXTimer = null;
70
+ if (!this.disposed) this.resizeX(this.latestX);
71
+ }, DEBOUNCE_RESIZE_X_DELAY);
72
+ }
73
+
74
+ _cancelDebouncedResizeX() {
75
+ if (this.resizeXTimer !== null) {
76
+ clearTimeout(this.resizeXTimer);
77
+ this.resizeXTimer = null;
78
+ }
79
+ }
80
+
81
+ _scheduleIdleResizeX() {
82
+ this._cancelDebouncedResizeX();
83
+ if (this.resizeXIdle !== null) return;
84
+ this.resizeXIdle = this._requestIdle(() => {
85
+ this.resizeXIdle = null;
86
+ if (!this.disposed) this.resizeX(this.latestX);
87
+ });
88
+ }
89
+
90
+ _scheduleIdleResizeY() {
91
+ if (this.resizeYIdle !== null) return;
92
+ this.resizeYIdle = this._requestIdle(() => {
93
+ this.resizeYIdle = null;
94
+ if (!this.disposed) this.resizeY(this.latestY);
95
+ });
96
+ }
97
+
98
+ _cancelIdleResizeX() {
99
+ if (this.resizeXIdle !== null) {
100
+ this._cancelIdle(this.resizeXIdle);
101
+ this.resizeXIdle = null;
102
+ }
103
+ }
104
+
105
+ _cancelIdleResizeY() {
106
+ if (this.resizeYIdle !== null) {
107
+ this._cancelIdle(this.resizeYIdle);
108
+ this.resizeYIdle = null;
109
+ }
110
+ }
111
+
112
+ _requestIdle(callback) {
113
+ if (window.requestIdleCallback) {
114
+ return { kind: 'idle', id: window.requestIdleCallback(callback) };
115
+ }
116
+ return { kind: 'timeout', id: setTimeout(callback, 50) };
117
+ }
118
+
119
+ _cancelIdle(handle) {
120
+ if (handle.kind === 'idle') {
121
+ window.cancelIdleCallback?.(handle.id);
122
+ } else {
123
+ clearTimeout(handle.id);
124
+ }
125
+ }
126
+ }
@@ -9,6 +9,10 @@ import { ClipboardAddon } from '@xterm/addon-clipboard';
9
9
  import { WebglAddon } from '@xterm/addon-webgl';
10
10
  import { isDarkTheme } from '../state.js';
11
11
 
12
+ const DEFAULT_COLS = 80;
13
+ const DEFAULT_ROWS = 24;
14
+ const SCROLLBAR_WIDTH_FALLBACK = 14;
15
+
12
16
  // Dark xterm theme - VSCode's Dark+ terminal palette, verbatim (see
13
17
  // microsoft/vscode src/.../terminal/common/terminalColorRegistry.ts).
14
18
  const THEME_DARK = {
@@ -47,18 +51,24 @@ const THEME_LIGHT = {
47
51
 
48
52
  export const themeFor = (dark) => (dark ? THEME_DARK : THEME_LIGHT);
49
53
 
54
+ let lastKnownGridDimensions = { cols: DEFAULT_COLS, rows: DEFAULT_ROWS };
55
+
50
56
  export class XtermTerminal {
51
57
  constructor() {
52
58
  this.isMobile = window.matchMedia('(max-width: 640px)').matches;
53
59
  this.currentTheme = themeFor(isDarkTheme());
54
60
  this.fitAddon = new FitAddon();
55
61
  this.webglAddon = null;
62
+ this.webglContextLossDisposable = null;
63
+ this.refreshDimensionListeners = new Set();
56
64
  this.host = null;
57
65
 
58
66
  this.raw = new Terminal({
59
67
  fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
60
68
  fontSize: this.isMobile ? 11 : 13,
61
69
  lineHeight: 1.2,
70
+ cols: lastKnownGridDimensions.cols,
71
+ rows: lastKnownGridDimensions.rows,
62
72
  cursorBlink: true,
63
73
  cursorStyle: 'bar',
64
74
  scrollback: 5000,
@@ -74,12 +84,12 @@ export class XtermTerminal {
74
84
  this.raw.loadAddon(this.fitAddon);
75
85
  this.raw.loadAddon(new WebLinksAddon());
76
86
  this.raw.loadAddon(new ClipboardAddon());
77
- this._loadRendererAddon();
78
87
  this._installSelectionCopyGuard();
79
88
  }
80
89
 
81
90
  get cols() { return this.raw.cols; }
82
91
  get rows() { return this.raw.rows; }
92
+ get normalBufferLength() { return this.raw.buffer?.normal?.length ?? 0; }
83
93
  get theme() { return this.currentTheme; }
84
94
  get parser() { return this.raw.parser; }
85
95
  get helperTextarea() {
@@ -89,14 +99,21 @@ export class XtermTerminal {
89
99
  attachToElement(host) {
90
100
  this.host = host;
91
101
  this.raw.open(host);
92
- this.scheduleFit();
102
+ this._enableWebglRenderer();
93
103
  try {
94
104
  document.fonts?.ready?.then(() => {
95
- if (this.host === host) this.scheduleFit();
105
+ if (this.host === host) this._fireRequestRefreshDimensions();
96
106
  });
97
107
  } catch {}
98
108
  }
99
109
 
110
+ onDidRequestRefreshDimensions(listener) {
111
+ this.refreshDimensionListeners.add(listener);
112
+ return {
113
+ dispose: () => this.refreshDimensionListeners.delete(listener),
114
+ };
115
+ }
116
+
100
117
  applyResolvedTheme() {
101
118
  const theme = themeFor(isDarkTheme());
102
119
  this.currentTheme = theme;
@@ -120,13 +137,33 @@ export class XtermTerminal {
120
137
  try { this.raw.write('\x1b[?25l'); } catch {}
121
138
  }
122
139
 
123
- scheduleFit() {
124
- this.fit();
125
- requestAnimationFrame(() => {
126
- this.fit();
127
- setTimeout(() => this.fit(), 60);
128
- setTimeout(() => this.fit(), 200);
129
- });
140
+ layoutFromElement() {
141
+ if (!this.host) return null;
142
+ const rect = this.host.getBoundingClientRect();
143
+ return this.layout(rect.width, rect.height);
144
+ }
145
+
146
+ layout(width, height) {
147
+ if (!(width > 0 && height > 0)) return null;
148
+
149
+ const proposed = this._proposeDimensions(width, height);
150
+ if (!proposed) return null;
151
+
152
+ if (proposed.cols !== this.raw.cols || proposed.rows !== this.raw.rows) {
153
+ try { this.raw.resize(proposed.cols, proposed.rows); } catch {}
154
+ }
155
+ lastKnownGridDimensions = proposed;
156
+ return proposed;
157
+ }
158
+
159
+ proposeDimensions(width, height) {
160
+ return this._proposeDimensions(width, height);
161
+ }
162
+
163
+ resize(cols, rows) {
164
+ if (!(cols > 0 && rows > 0)) return;
165
+ try { this.raw.resize(cols, rows); } catch {}
166
+ lastKnownGridDimensions = { cols: this.raw.cols, rows: this.raw.rows };
130
167
  }
131
168
 
132
169
  fit() {
@@ -141,8 +178,13 @@ export class XtermTerminal {
141
178
  try { this.raw.clearTextureAtlas?.(); } catch {}
142
179
  }
143
180
 
181
+ forceRedraw() {
182
+ this.clearTextureAtlas();
183
+ this.refresh();
184
+ }
185
+
144
186
  write(data, callback) {
145
- try { this.raw.write(data, callback); } catch {}
187
+ try { this.raw.write(data, callback); } catch { callback?.(); }
146
188
  }
147
189
 
148
190
  reset() {
@@ -167,20 +209,50 @@ export class XtermTerminal {
167
209
 
168
210
  dispose() {
169
211
  this.host = null;
212
+ this._disposeWebglRenderer(false);
213
+ this.refreshDimensionListeners.clear();
170
214
  try { this.raw.dispose(); } catch {}
171
215
  }
172
216
 
173
- _loadRendererAddon() {
217
+ _shouldLoadWebgl() {
218
+ return !this.isMobile && XtermTerminal._suggestedRendererType !== 'dom';
219
+ }
220
+
221
+ _enableWebglRenderer() {
174
222
  // Keep the current mobile guard: @xterm/addon-webgl@0.18 can mis-measure
175
223
  // glyph atlases on fractional mobile DPRs.
176
- if (this.isMobile) return;
224
+ if (!this.raw.element || !this._shouldLoadWebgl()) return;
225
+ this._disposeWebglRenderer(false);
177
226
  try {
178
227
  const webgl = new WebglAddon();
179
228
  this.webglAddon = webgl;
180
- webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
229
+ this.webglContextLossDisposable = webgl.onContextLoss(() => {
230
+ console.warn('[ccsm] WebGL context lost, using DOM renderer');
231
+ this._disposeWebglRenderer();
232
+ });
181
233
  this.raw.loadAddon(webgl);
234
+ this._fireRequestRefreshDimensions();
182
235
  } catch (e) {
236
+ XtermTerminal._suggestedRendererType = 'dom';
237
+ this._disposeWebglRenderer(false);
183
238
  console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
239
+ this._fireRequestRefreshDimensions();
240
+ }
241
+ }
242
+
243
+ _disposeWebglRenderer(requestRefresh = true) {
244
+ try { this.webglContextLossDisposable?.dispose(); } catch {}
245
+ this.webglContextLossDisposable = null;
246
+ if (this.webglAddon) {
247
+ try { this.webglAddon.dispose(); } catch {}
248
+ this.webglAddon = null;
249
+ }
250
+ if (requestRefresh) this._fireRequestRefreshDimensions();
251
+ }
252
+
253
+ _fireRequestRefreshDimensions() {
254
+ for (const listener of this.refreshDimensionListeners) {
255
+ try { listener(); } catch {}
184
256
  }
185
257
  }
186
258
 
@@ -195,4 +267,66 @@ export class XtermTerminal {
195
267
  return true;
196
268
  });
197
269
  }
270
+
271
+ _proposeDimensions(width, height) {
272
+ const cell = this._cellDimensions();
273
+ if (!cell) return null;
274
+
275
+ const elementStyle = this.raw.element
276
+ ? window.getComputedStyle(this.raw.element)
277
+ : null;
278
+ const px = (v) => Number.parseFloat(v || '0') || 0;
279
+ const horizontalPadding = elementStyle
280
+ ? px(elementStyle.paddingLeft) + px(elementStyle.paddingRight)
281
+ : 0;
282
+ const verticalPadding = elementStyle
283
+ ? px(elementStyle.paddingTop) + px(elementStyle.paddingBottom)
284
+ : 0;
285
+ const scrollbarWidth = this._scrollbarWidth();
286
+
287
+ const availableWidth = Math.max(0, width - horizontalPadding - scrollbarWidth);
288
+ const availableHeight = Math.max(0, height - verticalPadding);
289
+ if (!(availableWidth > 0 && availableHeight > 0)) return null;
290
+
291
+ const dpr = window.devicePixelRatio || 1;
292
+ const scaledWidth = availableWidth * dpr;
293
+ const scaledCellWidth = cell.width * dpr;
294
+ const scaledHeight = availableHeight * dpr;
295
+ const scaledCellHeight = Math.ceil(cell.height * dpr);
296
+
297
+ return {
298
+ cols: Math.max(1, Math.floor(scaledWidth / scaledCellWidth)),
299
+ rows: Math.max(1, Math.floor(scaledHeight / scaledCellHeight)),
300
+ };
301
+ }
302
+
303
+ _cellDimensions() {
304
+ const cell = this.raw?._core?._renderService?.dimensions?.css?.cell;
305
+ if (cell?.width > 0 && cell?.height > 0) {
306
+ return { width: cell.width, height: cell.height };
307
+ }
308
+
309
+ const proposed = (() => {
310
+ try { return this.fitAddon.proposeDimensions?.(); } catch { return null; }
311
+ })();
312
+ if (proposed?.cols > 0 && proposed?.rows > 0 && this.host) {
313
+ const rect = this.host.getBoundingClientRect();
314
+ return {
315
+ width: rect.width / proposed.cols,
316
+ height: rect.height / proposed.rows,
317
+ };
318
+ }
319
+ return null;
320
+ }
321
+
322
+ _scrollbarWidth() {
323
+ const core = this.raw?._core;
324
+ const width =
325
+ core?._viewport?.scrollBarWidth ??
326
+ core?.viewport?.scrollBarWidth ??
327
+ 0;
328
+ return width > 0 ? width : SCROLLBAR_WIDTH_FALLBACK;
329
+ }
198
330
  }
331
+
332
+ XtermTerminal._suggestedRendererType = undefined;