castle-web-cli 0.4.0 → 0.4.2

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 (158) hide show
  1. package/dist/api.d.ts +53 -5
  2. package/dist/api.js +42 -15
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js +25 -11
  5. package/dist/get-deck.d.ts +3 -0
  6. package/dist/get-deck.js +64 -0
  7. package/dist/ide-client.d.ts +1 -0
  8. package/dist/ide-client.js +537 -0
  9. package/dist/ide.d.ts +16 -0
  10. package/dist/ide.js +546 -0
  11. package/dist/index.js +84 -57
  12. package/dist/init.d.ts +3 -1
  13. package/dist/init.js +170 -24
  14. package/dist/localPaths.d.ts +6 -0
  15. package/dist/localPaths.js +33 -0
  16. package/dist/login.js +1 -1
  17. package/dist/preview.d.ts +4 -1
  18. package/dist/preview.js +63 -41
  19. package/dist/save-deck.d.ts +2 -0
  20. package/dist/{push.js → save-deck.js} +66 -5
  21. package/dist/serve.d.ts +2 -0
  22. package/dist/serve.js +293 -22
  23. package/kits/basic-2d/.prettierrc +8 -0
  24. package/kits/basic-2d/CLAUDE.md +131 -0
  25. package/kits/basic-2d/behaviors/Camera.jsx +43 -0
  26. package/kits/basic-2d/behaviors/Collider.jsx +71 -0
  27. package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
  28. package/kits/basic-2d/behaviors/Layout.jsx +16 -0
  29. package/kits/basic-2d/drawings/floor.drawing +70 -0
  30. package/kits/basic-2d/editors/App.jsx +152 -0
  31. package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
  32. package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
  33. package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
  34. package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
  35. package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
  36. package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
  37. package/kits/basic-2d/editors/editorHistory.js +52 -0
  38. package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
  39. package/kits/basic-2d/engine/SceneUI.jsx +67 -0
  40. package/kits/basic-2d/engine/TouchControls.jsx +136 -0
  41. package/kits/basic-2d/engine/autoInspector.jsx +51 -0
  42. package/kits/basic-2d/engine/files.js +62 -0
  43. package/kits/basic-2d/engine/scene.js +420 -0
  44. package/kits/basic-2d/engine/ui.jsx +344 -0
  45. package/kits/basic-2d/engine/ui.module.css +928 -0
  46. package/kits/basic-2d/eslint.config.js +50 -0
  47. package/kits/basic-2d/index.html +11 -0
  48. package/kits/basic-2d/main.jsx +10 -0
  49. package/kits/basic-2d/package-lock.json +2706 -0
  50. package/kits/basic-2d/package.json +41 -0
  51. package/kits/basic-2d/scenes/main.scene +108 -0
  52. package/kits/basic-2d/vite.config.js +1 -0
  53. package/kits/basic-2d-frozen/.prettierrc +8 -0
  54. package/kits/basic-2d-frozen/CLAUDE.md +131 -0
  55. package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
  56. package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
  57. package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
  58. package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
  59. package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
  60. package/kits/basic-2d-frozen/editors/App.jsx +152 -0
  61. package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
  62. package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
  63. package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
  64. package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
  65. package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
  66. package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
  67. package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
  68. package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
  69. package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
  70. package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
  71. package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
  72. package/kits/basic-2d-frozen/engine/files.js +62 -0
  73. package/kits/basic-2d-frozen/engine/scene.js +420 -0
  74. package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
  75. package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
  76. package/kits/basic-2d-frozen/eslint.config.js +50 -0
  77. package/kits/basic-2d-frozen/index.html +11 -0
  78. package/kits/basic-2d-frozen/main.jsx +10 -0
  79. package/kits/basic-2d-frozen/package-lock.json +2706 -0
  80. package/kits/basic-2d-frozen/package.json +41 -0
  81. package/kits/basic-2d-frozen/scenes/main.scene +108 -0
  82. package/kits/basic-2d-frozen/vite.config.js +1 -0
  83. package/kits/rpg-2d/.prettierrc +8 -0
  84. package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
  85. package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
  86. package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
  87. package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
  88. package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
  89. package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
  90. package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
  91. package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
  92. package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
  93. package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
  94. package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
  95. package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
  96. package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
  97. package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
  98. package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
  99. package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
  100. package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
  101. package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
  102. package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
  103. package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
  104. package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
  105. package/kits/rpg-2d/drawings/floor.drawing +70 -0
  106. package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
  107. package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
  108. package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
  109. package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
  110. package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
  111. package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
  112. package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
  113. package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
  114. package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
  115. package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
  116. package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
  117. package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
  118. package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
  119. package/kits/rpg-2d/editors/App.tsx +163 -0
  120. package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
  121. package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
  122. package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
  123. package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
  124. package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
  125. package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
  126. package/kits/rpg-2d/editors/editorHistory.ts +75 -0
  127. package/kits/rpg-2d/editors/editorProps.ts +10 -0
  128. package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
  129. package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
  130. package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
  131. package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
  132. package/kits/rpg-2d/engine/drawing.ts +81 -0
  133. package/kits/rpg-2d/engine/files.ts +215 -0
  134. package/kits/rpg-2d/engine/scene.ts +484 -0
  135. package/kits/rpg-2d/engine/ui.module.css +928 -0
  136. package/kits/rpg-2d/engine/ui.tsx +483 -0
  137. package/kits/rpg-2d/eslint.config.js +46 -0
  138. package/kits/rpg-2d/index.html +11 -0
  139. package/kits/rpg-2d/main.tsx +14 -0
  140. package/kits/rpg-2d/package-lock.json +3149 -0
  141. package/kits/rpg-2d/package.json +46 -0
  142. package/kits/rpg-2d/scenes/main.scene +203 -0
  143. package/kits/rpg-2d/tsconfig.json +17 -0
  144. package/kits/rpg-2d/vite-env.d.ts +7 -0
  145. package/kits/rpg-2d/vite.config.js +1 -0
  146. package/package.json +27 -5
  147. package/AGENTS.md +0 -24
  148. package/dist/push.d.ts +0 -1
  149. package/src/api.ts +0 -160
  150. package/src/bundle.ts +0 -28
  151. package/src/config.ts +0 -36
  152. package/src/index.ts +0 -110
  153. package/src/init.ts +0 -71
  154. package/src/login.ts +0 -24
  155. package/src/preview.ts +0 -93
  156. package/src/push.ts +0 -118
  157. package/src/serve.ts +0 -128
  158. package/tsconfig.json +0 -13
@@ -0,0 +1,537 @@
1
+ // Browser-side terminal for the `castle-web serve --ide` panel. Plain DOM (no
2
+ // framework) so it can be served straight from dist with no bundler. xterm.js
3
+ // and addon-fit are loaded as UMD <script> tags before this file, so they
4
+ // arrive as the `Terminal` and `FitAddon` globals.
5
+ //
6
+ // Workarounds ported from castle-cli's `ide` branch and lemo threads' xterm:
7
+ // backoff reconnect + wake handlers, screen replay with a multi-stage repaint
8
+ // repair, FitAddon resize with send-dedup, wheel scroll-chain containment, and
9
+ // the text-presentation fix for symbols browsers like to emoji-render.
10
+ (function () {
11
+ const host = document.getElementById('term-host');
12
+ if (!host)
13
+ return;
14
+ const toggleButton = document.getElementById('term-toggle');
15
+ const reconnectDelaysMs = [250, 500, 1000, 2000, 4000, 8000, 12000, 15000];
16
+ // After a replay, xterm's grid can render at a stale size until a few
17
+ // repaints land; threads found this cadence reliable.
18
+ const replayRepairDelaysMs = [50, 160];
19
+ // Force text presentation for symbols browsers render as wide colour emoji;
20
+ // without it bullets/arrows in CLI output break the terminal grid.
21
+ const textPresentationPattern = /[•‣⁃∙■-◿☀-➿⬀-⯿]/g;
22
+ const variationSelectorText = '︎';
23
+ function forceTextSymbols(value) {
24
+ return value.replace(textPresentationPattern, (c) => `${c}${variationSelectorText}`);
25
+ }
26
+ // Terminal themes. The dark palette is Tokyo Night (from the cli `ide`
27
+ // branch); the choice persists in localStorage and is set from the cog.
28
+ const lightTheme = {
29
+ background: '#ffffff',
30
+ foreground: '#1f2328',
31
+ cursor: '#1f2328',
32
+ cursorAccent: '#ffffff',
33
+ selectionBackground: '#cfe0ff',
34
+ selectionForeground: '#1f2328',
35
+ black: '#24292e',
36
+ red: '#cf222e',
37
+ green: '#116329',
38
+ yellow: '#7d4e00',
39
+ blue: '#0969da',
40
+ magenta: '#8250df',
41
+ cyan: '#1b7c83',
42
+ white: '#6e7781',
43
+ brightBlack: '#57606a',
44
+ brightRed: '#a40e26',
45
+ brightGreen: '#1a7f37',
46
+ brightYellow: '#9a6700',
47
+ brightBlue: '#218bff',
48
+ brightMagenta: '#a475f9',
49
+ brightCyan: '#3192aa',
50
+ brightWhite: '#8c959f',
51
+ };
52
+ const darkTheme = {
53
+ background: '#1a1b26',
54
+ foreground: '#c0caf5',
55
+ cursor: '#c0caf5',
56
+ cursorAccent: '#1a1b26',
57
+ selectionBackground: '#283457',
58
+ selectionForeground: '#c0caf5',
59
+ black: '#15161e',
60
+ red: '#f7768e',
61
+ green: '#9ece6a',
62
+ yellow: '#e0af68',
63
+ blue: '#7aa2f7',
64
+ magenta: '#bb9af7',
65
+ cyan: '#7dcfff',
66
+ white: '#a9b1d6',
67
+ brightBlack: '#414868',
68
+ brightRed: '#f7768e',
69
+ brightGreen: '#9ece6a',
70
+ brightYellow: '#e0af68',
71
+ brightBlue: '#7aa2f7',
72
+ brightMagenta: '#bb9af7',
73
+ brightCyan: '#7dcfff',
74
+ brightWhite: '#c0caf5',
75
+ };
76
+ const themeStorageKey = 'castle-terminal-theme';
77
+ function storedThemeName() {
78
+ try {
79
+ return localStorage.getItem(themeStorageKey) === 'dark' ? 'dark' : 'light';
80
+ }
81
+ catch {
82
+ return 'light';
83
+ }
84
+ }
85
+ let themeName = storedThemeName();
86
+ const term = new Terminal({
87
+ convertEol: false,
88
+ cursorBlink: true,
89
+ macOptionIsMeta: true,
90
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
91
+ fontSize: 12,
92
+ fontWeight: 400,
93
+ fontWeightBold: 700,
94
+ letterSpacing: 0,
95
+ lineHeight: 1.25,
96
+ scrollback: 4000,
97
+ theme: themeName === 'dark' ? darkTheme : lightTheme,
98
+ });
99
+ const fit = new FitAddon.FitAddon();
100
+ term.loadAddon(fit);
101
+ term.open(host);
102
+ fit.fit();
103
+ term.focus();
104
+ // The PTY is lazy: the WebSocket isn't opened (and the server doesn't spawn
105
+ // a shell) until the user first reveals the terminal. If they never click
106
+ // the toggle, no PTY ever runs.
107
+ let activated = false;
108
+ let socket = null;
109
+ let socketToken = 0;
110
+ let reconnectAttempt = 0;
111
+ let reconnectTimer = null;
112
+ let reconnectEnabled = true;
113
+ let needsWakeReconnect = document.visibilityState === 'hidden';
114
+ let lastSentCols = 0;
115
+ let lastSentRows = 0;
116
+ const intentionallyClosed = new WeakSet();
117
+ const replayRepairFrames = [];
118
+ const replayRepairTimers = [];
119
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
120
+ const wsUrl = `${wsProtocol}//${window.location.host}/__castle/pty`;
121
+ //
122
+ // Toggle: hide the panel without unmounting / resizing xterm, so the text
123
+ // layout survives a hide/show cycle untouched (CSS keeps #term at 500px).
124
+ //
125
+ const openStorageKey = 'castle-terminal-open';
126
+ function storedOpen() {
127
+ try {
128
+ return localStorage.getItem(openStorageKey) === '1';
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ function persistOpen(open) {
135
+ try {
136
+ localStorage.setItem(openStorageKey, open ? '1' : '0');
137
+ }
138
+ catch {
139
+ /* storage disabled -- state just won't persist */
140
+ }
141
+ }
142
+ // First reveal spawns the PTY; later reveals just re-focus a live one.
143
+ function activateTerminal() {
144
+ if (activated)
145
+ return;
146
+ activated = true;
147
+ connect();
148
+ }
149
+ if (toggleButton) {
150
+ toggleButton.addEventListener('click', () => {
151
+ const open = document.body.classList.toggle('term-open');
152
+ persistOpen(open);
153
+ // The header strip is a flex item that appears/disappears with
154
+ // `term-open`, so the xterm host's leftover height changes -- re-fit so
155
+ // the grid matches the new content height (the ResizeObserver also
156
+ // catches this, but fit here makes the relayout immediate).
157
+ fitAndSendResize();
158
+ if (!open) {
159
+ document.body.classList.remove('term-settings-open');
160
+ return;
161
+ }
162
+ activateTerminal();
163
+ term.focus();
164
+ });
165
+ }
166
+ //
167
+ // Settings cog: a small popover with a light/dark theme switch for the
168
+ // terminal. The choice persists in localStorage.
169
+ //
170
+ const settingsButton = document.getElementById('term-settings');
171
+ const settingsPanel = document.getElementById('term-settings-panel');
172
+ const segButtons = Array.from(document.querySelectorAll('#term-theme-seg button'));
173
+ function applyTheme(name) {
174
+ themeName = name;
175
+ term.options.theme = name === 'dark' ? darkTheme : lightTheme;
176
+ document.body.classList.toggle('term-dark', name === 'dark');
177
+ for (const button of segButtons) {
178
+ button.classList.toggle('active', button.dataset.theme === name);
179
+ }
180
+ try {
181
+ localStorage.setItem(themeStorageKey, name);
182
+ }
183
+ catch {
184
+ /* storage disabled -- theme just won't persist */
185
+ }
186
+ }
187
+ applyTheme(themeName);
188
+ if (settingsButton) {
189
+ settingsButton.addEventListener('click', (event) => {
190
+ event.stopPropagation();
191
+ document.body.classList.toggle('term-settings-open');
192
+ });
193
+ }
194
+ for (const button of segButtons) {
195
+ button.addEventListener('click', () => {
196
+ applyTheme(button.dataset.theme === 'dark' ? 'dark' : 'light');
197
+ });
198
+ }
199
+ // A click anywhere outside the popover (or cog) closes it.
200
+ document.addEventListener('click', (event) => {
201
+ if (!document.body.classList.contains('term-settings-open'))
202
+ return;
203
+ const target = event.target;
204
+ if (settingsPanel && settingsPanel.contains(target))
205
+ return;
206
+ if (settingsButton && settingsButton.contains(target))
207
+ return;
208
+ document.body.classList.remove('term-settings-open');
209
+ });
210
+ //
211
+ // Ctrl+T moves keyboard focus between the terminal and the deck iframe.
212
+ // While the iframe holds focus the parent sees no keydowns, so the deck
213
+ // page forwards Ctrl+T via postMessage (see DECK_FOCUS_SCRIPT in ide.ts).
214
+ // The focused side persists in localStorage and is restored on load.
215
+ //
216
+ const deckFrame = document.getElementById('deck-frame');
217
+ const focusStorageKey = 'castle-terminal-focus';
218
+ function storedFocus() {
219
+ try {
220
+ return localStorage.getItem(focusStorageKey) === 'terminal' ? 'terminal' : 'deck';
221
+ }
222
+ catch {
223
+ return 'deck';
224
+ }
225
+ }
226
+ let focusTarget = storedFocus();
227
+ function setFocus(target) {
228
+ focusTarget = target;
229
+ try {
230
+ localStorage.setItem(focusStorageKey, target);
231
+ }
232
+ catch {
233
+ /* storage disabled -- focus side just won't persist */
234
+ }
235
+ if (target === 'terminal') {
236
+ // Focusing the terminal implies revealing it.
237
+ if (!document.body.classList.contains('term-open')) {
238
+ document.body.classList.add('term-open');
239
+ persistOpen(true);
240
+ activateTerminal();
241
+ }
242
+ term.focus();
243
+ }
244
+ else {
245
+ deckFrame?.contentWindow?.focus();
246
+ }
247
+ }
248
+ function toggleFocus() {
249
+ setFocus(focusTarget === 'terminal' ? 'deck' : 'terminal');
250
+ }
251
+ // Ctrl+T while the parent (terminal or shell chrome) holds focus. Capture
252
+ // phase + stopImmediatePropagation so xterm never receives the keystroke.
253
+ document.addEventListener('keydown', (event) => {
254
+ if (event.ctrlKey &&
255
+ !event.metaKey &&
256
+ !event.altKey &&
257
+ !event.shiftKey &&
258
+ (event.key === 't' || event.key === 'T')) {
259
+ event.preventDefault();
260
+ event.stopImmediatePropagation();
261
+ toggleFocus();
262
+ }
263
+ }, true);
264
+ // Ctrl+T forwarded from inside the deck iframe.
265
+ window.addEventListener('message', (event) => {
266
+ if (!event.data || event.data.type !== 'castle-ide-toggle-focus')
267
+ return;
268
+ if (deckFrame && event.source !== deckFrame.contentWindow)
269
+ return;
270
+ toggleFocus();
271
+ });
272
+ // Keep focusTarget honest when focus moves by click rather than Ctrl+T.
273
+ if (term.textarea) {
274
+ term.textarea.addEventListener('focus', () => {
275
+ focusTarget = 'terminal';
276
+ });
277
+ }
278
+ window.addEventListener('blur', () => {
279
+ if (deckFrame && document.activeElement === deckFrame)
280
+ focusTarget = 'deck';
281
+ });
282
+ function clearReconnectTimer() {
283
+ if (reconnectTimer === null)
284
+ return;
285
+ window.clearTimeout(reconnectTimer);
286
+ reconnectTimer = null;
287
+ }
288
+ // xterm sometimes leaves stale rows after a resize/replay; force a redraw.
289
+ function refreshRows() {
290
+ if (term.rows > 0)
291
+ term.refresh(0, term.rows - 1);
292
+ }
293
+ function sendResize() {
294
+ if (!socket || socket.readyState !== WebSocket.OPEN)
295
+ return;
296
+ if (term.cols === lastSentCols && term.rows === lastSentRows)
297
+ return;
298
+ lastSentCols = term.cols;
299
+ lastSentRows = term.rows;
300
+ socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
301
+ }
302
+ function fitNow() {
303
+ fit.fit();
304
+ sendResize();
305
+ }
306
+ function fitAndSendResize() {
307
+ window.requestAnimationFrame(() => {
308
+ fitNow();
309
+ refreshRows();
310
+ });
311
+ }
312
+ //
313
+ // Replay repaint repair (ported from threads): a replayed screen can render
314
+ // at a stale size; repaint across a microtask, two animation frames, and a
315
+ // couple of timers so the grid settles whatever the layout timing.
316
+ //
317
+ function clearReplayRepairWork() {
318
+ while (replayRepairFrames.length > 0) {
319
+ const frame = replayRepairFrames.pop();
320
+ if (frame !== undefined)
321
+ window.cancelAnimationFrame(frame);
322
+ }
323
+ while (replayRepairTimers.length > 0) {
324
+ const timer = replayRepairTimers.pop();
325
+ if (timer !== undefined)
326
+ window.clearTimeout(timer);
327
+ }
328
+ }
329
+ function repairReplayLayout() {
330
+ fitNow();
331
+ refreshRows();
332
+ }
333
+ function scheduleReplayRepair() {
334
+ clearReplayRepairWork();
335
+ window.queueMicrotask(repairReplayLayout);
336
+ replayRepairFrames.push(window.requestAnimationFrame(() => {
337
+ repairReplayLayout();
338
+ replayRepairFrames.push(window.requestAnimationFrame(repairReplayLayout));
339
+ }));
340
+ for (const delay of replayRepairDelaysMs) {
341
+ replayRepairTimers.push(window.setTimeout(repairReplayLayout, delay));
342
+ }
343
+ }
344
+ function handleMessage(msg) {
345
+ if (msg.type === 'replay') {
346
+ // Replay must land on a blank terminal, then repaint once layout settles.
347
+ term.reset();
348
+ term.write(forceTextSymbols(String(msg.data ?? '')), scheduleReplayRepair);
349
+ }
350
+ else if (msg.type === 'output') {
351
+ term.write(forceTextSymbols(String(msg.data ?? '')));
352
+ }
353
+ else if (msg.type === 'exit') {
354
+ reconnectEnabled = false;
355
+ clearReconnectTimer();
356
+ }
357
+ }
358
+ function scheduleReconnect() {
359
+ if (!reconnectEnabled)
360
+ return;
361
+ clearReconnectTimer();
362
+ if (document.visibilityState === 'hidden' || navigator.onLine === false)
363
+ return;
364
+ if (reconnectAttempt >= reconnectDelaysMs.length)
365
+ return;
366
+ const delay = reconnectDelaysMs[reconnectAttempt];
367
+ reconnectAttempt += 1;
368
+ reconnectTimer = window.setTimeout(connect, delay);
369
+ }
370
+ function connect() {
371
+ if (!reconnectEnabled)
372
+ return;
373
+ clearReconnectTimer();
374
+ if (document.visibilityState === 'hidden' || navigator.onLine === false)
375
+ return;
376
+ if (socket) {
377
+ intentionallyClosed.add(socket);
378
+ try {
379
+ socket.close(1000, 'reconnect');
380
+ }
381
+ catch {
382
+ /* ignore */
383
+ }
384
+ }
385
+ const ws = new WebSocket(wsUrl);
386
+ const token = ++socketToken;
387
+ socket = ws;
388
+ lastSentCols = 0;
389
+ lastSentRows = 0;
390
+ ws.addEventListener('open', () => {
391
+ if (socketToken !== token)
392
+ return;
393
+ reconnectAttempt = 0;
394
+ fitAndSendResize();
395
+ });
396
+ ws.addEventListener('message', (event) => {
397
+ if (socketToken !== token)
398
+ return;
399
+ try {
400
+ handleMessage(JSON.parse(String(event.data)));
401
+ }
402
+ catch {
403
+ /* ignore malformed frame */
404
+ }
405
+ });
406
+ ws.addEventListener('close', () => {
407
+ if (socketToken !== token)
408
+ return;
409
+ socket = null;
410
+ if (!reconnectEnabled || intentionallyClosed.has(ws))
411
+ return;
412
+ scheduleReconnect();
413
+ });
414
+ ws.addEventListener('error', () => {
415
+ /* the close handler drives reconnect */
416
+ });
417
+ }
418
+ //
419
+ // Wake handling: a backgrounded / offline tab drops its socket; reconnect as
420
+ // soon as it becomes visible, focused, or back online (ported from `ide`).
421
+ //
422
+ function wake(force) {
423
+ if (!activated || !reconnectEnabled || document.visibilityState === 'hidden')
424
+ return;
425
+ reconnectAttempt = 0;
426
+ if (socket && socket.readyState === WebSocket.OPEN) {
427
+ fitAndSendResize();
428
+ return;
429
+ }
430
+ if (socket && socket.readyState === WebSocket.CONNECTING)
431
+ return;
432
+ if (force || !socket || socket.readyState >= WebSocket.CLOSING)
433
+ connect();
434
+ }
435
+ document.addEventListener('visibilitychange', () => {
436
+ if (document.visibilityState === 'hidden') {
437
+ needsWakeReconnect = true;
438
+ clearReconnectTimer();
439
+ return;
440
+ }
441
+ const force = needsWakeReconnect;
442
+ needsWakeReconnect = false;
443
+ wake(force);
444
+ });
445
+ window.addEventListener('pagehide', () => {
446
+ needsWakeReconnect = true;
447
+ if (socket) {
448
+ intentionallyClosed.add(socket);
449
+ try {
450
+ socket.close(1000, 'page hidden');
451
+ }
452
+ catch {
453
+ /* ignore */
454
+ }
455
+ }
456
+ clearReconnectTimer();
457
+ });
458
+ window.addEventListener('pageshow', (event) => {
459
+ const force = needsWakeReconnect || event.persisted;
460
+ needsWakeReconnect = false;
461
+ wake(force);
462
+ });
463
+ window.addEventListener('focus', () => {
464
+ const force = needsWakeReconnect;
465
+ needsWakeReconnect = false;
466
+ wake(force);
467
+ });
468
+ window.addEventListener('offline', () => {
469
+ needsWakeReconnect = true;
470
+ clearReconnectTimer();
471
+ });
472
+ window.addEventListener('online', () => {
473
+ const force = needsWakeReconnect;
474
+ needsWakeReconnect = false;
475
+ wake(force);
476
+ });
477
+ // Send Ctrl+Enter as CSI 13;5u so claude/codex CLIs receive newline+modifier.
478
+ term.attachCustomKeyEventHandler((ev) => {
479
+ if (ev.type !== 'keydown')
480
+ return true;
481
+ if (ev.key === 'Enter' && ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
482
+ ev.preventDefault();
483
+ if (socket && socket.readyState === WebSocket.OPEN) {
484
+ socket.send(JSON.stringify({ type: 'input', data: '\x1b[13;5u' }));
485
+ }
486
+ return false;
487
+ }
488
+ return true;
489
+ });
490
+ term.onData((data) => {
491
+ if (socket && socket.readyState === WebSocket.OPEN) {
492
+ socket.send(JSON.stringify({ type: 'input', data }));
493
+ }
494
+ });
495
+ // Keep wheel scrolling inside the terminal -- don't chain to the host page
496
+ // when the viewport is at its top/bottom (or has nothing to scroll).
497
+ host.addEventListener('wheel', (event) => {
498
+ const viewport = host.querySelector('.xterm-viewport');
499
+ if (!viewport)
500
+ return;
501
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
502
+ if (maxScrollTop <= 0) {
503
+ event.preventDefault();
504
+ return;
505
+ }
506
+ const pastTop = viewport.scrollTop <= 0 && event.deltaY < 0;
507
+ const pastBottom = viewport.scrollTop >= maxScrollTop - 1 && event.deltaY > 0;
508
+ if (pastTop || pastBottom)
509
+ event.preventDefault();
510
+ }, { passive: false });
511
+ const observer = new ResizeObserver(() => fitAndSendResize());
512
+ observer.observe(host);
513
+ // Restore the persisted open state: if the terminal was open last visit,
514
+ // reveal it and reconnect (re-attaching, or spawning, the pty).
515
+ if (storedOpen()) {
516
+ document.body.classList.add('term-open');
517
+ activateTerminal();
518
+ }
519
+ // Restore the persisted focus side. xterm needs a layout pass after
520
+ // term.open() before focus reliably lands on its helper textarea -- a
521
+ // synchronous focus here runs before xterm finishes mounting and silently
522
+ // fails. Defer the terminal-focus restore behind a fit + two animation
523
+ // frames so the grid has settled first (matching threads' post-layout
524
+ // focus sequencing).
525
+ if (storedFocus() === 'terminal' && document.body.classList.contains('term-open')) {
526
+ window.requestAnimationFrame(() => {
527
+ fitNow();
528
+ window.requestAnimationFrame(() => {
529
+ term.focus();
530
+ });
531
+ });
532
+ }
533
+ else if (deckFrame) {
534
+ deckFrame.contentWindow?.focus();
535
+ }
536
+ })();
537
+ export {};
package/dist/ide.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import * as http from 'http';
2
+ import { Duplex } from 'stream';
3
+ export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
4
+ export declare const PTY_WS_PATH = "/__castle/pty";
5
+ export declare const DECK_FOCUS_SCRIPT: string;
6
+ export interface IdeServer {
7
+ /** Serve the IDE page + its static assets. Returns true if it handled the request. */
8
+ handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse, reqPath: string): boolean;
9
+ /** Attach the PTY WebSocket if the upgrade targets the PTY path. */
10
+ handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): boolean;
11
+ shutdown(): void;
12
+ }
13
+ export declare function createIdeServer(opts: {
14
+ deckDir: string;
15
+ deckLabel: string;
16
+ }): IdeServer;