castle-web-cli 0.4.10 → 0.4.12

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 (50) hide show
  1. package/dist/agent-prompts.d.ts +31 -0
  2. package/dist/agent-prompts.js +100 -0
  3. package/dist/agent.d.ts +17 -0
  4. package/dist/agent.js +894 -0
  5. package/dist/chat-client.d.ts +1 -0
  6. package/dist/chat-client.js +398 -0
  7. package/dist/commonInstructions.d.ts +1 -0
  8. package/dist/commonInstructions.js +8 -0
  9. package/dist/ide-client.js +46 -14
  10. package/dist/ide.d.ts +2 -0
  11. package/dist/ide.js +321 -36
  12. package/dist/init.js +12 -2
  13. package/dist/serve.js +62 -3
  14. package/kits/basic-2d/CLAUDE.md +3 -1
  15. package/kits/basic-2d/package.json +0 -1
  16. package/kits/basic-3d/.prettierrc +8 -0
  17. package/kits/basic-3d/CLAUDE.md +162 -0
  18. package/kits/basic-3d/behaviors/Camera.jsx +56 -0
  19. package/kits/basic-3d/behaviors/Collider.jsx +78 -0
  20. package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
  21. package/kits/basic-3d/behaviors/Model.jsx +61 -0
  22. package/kits/basic-3d/behaviors/Transform.jsx +35 -0
  23. package/kits/basic-3d/editors/App.jsx +147 -0
  24. package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
  25. package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
  26. package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
  27. package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
  28. package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
  29. package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
  30. package/kits/basic-3d/editors/editorHistory.js +52 -0
  31. package/kits/basic-3d/editors/viewportRig.js +90 -0
  32. package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
  33. package/kits/basic-3d/engine/SceneUI.jsx +67 -0
  34. package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
  35. package/kits/basic-3d/engine/TouchControls.jsx +136 -0
  36. package/kits/basic-3d/engine/autoInspector.jsx +51 -0
  37. package/kits/basic-3d/engine/files.js +73 -0
  38. package/kits/basic-3d/engine/scene.js +502 -0
  39. package/kits/basic-3d/engine/threeUtil.js +260 -0
  40. package/kits/basic-3d/engine/ui.jsx +352 -0
  41. package/kits/basic-3d/engine/ui.module.css +944 -0
  42. package/kits/basic-3d/eslint.config.js +51 -0
  43. package/kits/basic-3d/index.html +11 -0
  44. package/kits/basic-3d/main.jsx +10 -0
  45. package/kits/basic-3d/models/block.model +14 -0
  46. package/kits/basic-3d/package-lock.json +2713 -0
  47. package/kits/basic-3d/package.json +41 -0
  48. package/kits/basic-3d/scenes/main.scene +76 -0
  49. package/kits/basic-3d/vite.config.js +1 -0
  50. package/package.json +6 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,398 @@
1
+ // Browser-side agent chat for the `castle-web serve` panel, as a React app.
2
+ // React + ReactDOM + marked arrive as UMD globals (served from node_modules
3
+ // like xterm); tsc compiles this file's JSX with the classic runtime, so it
4
+ // ships straight from dist with no bundler.
5
+ //
6
+ // Talks to the agent WebSocket at /__castle/agent: replays the conversation +
7
+ // tasks on connect, streams router replies, and live-updates the task board.
8
+ // The panel chrome shared with the terminal (Chat | Terminal view switch,
9
+ // progress-style setting) stays plain DOM on the static header elements.
10
+ // Reflect server-side agent settings onto the static popover segs. Plain DOM
11
+ // because the popover lives outside the React root.
12
+ function applyBackendSegs(settings) {
13
+ const segs = [
14
+ ['agent-router-seg', settings.router],
15
+ ['agent-tasks-seg', settings.tasks],
16
+ ['agent-model-seg', settings.claudeModel],
17
+ ];
18
+ for (const [segId, value] of segs) {
19
+ if (!value)
20
+ continue;
21
+ for (const button of document.querySelectorAll(`#${segId} button`)) {
22
+ button.classList.toggle('active', (button.dataset.backend ?? button.dataset.model) === value);
23
+ }
24
+ }
25
+ }
26
+ const TERMINAL_TASK_STATUSES = ['done', 'failed', 'interrupted'];
27
+ const ATTACHMENT_URL_PREFIX = '/__castle/agent/attachments/';
28
+ function renderMarkdown(text) {
29
+ try {
30
+ return { __html: marked.parse(text, { breaks: true }) };
31
+ }
32
+ catch {
33
+ return { __html: '' };
34
+ }
35
+ }
36
+ //
37
+ // Panel chrome: Chat | Terminal switch + progress-style setting. Plain DOM on
38
+ // the server-rendered header; ide-client listens for the same view events to
39
+ // run the terminal side (lazy PTY spawn, focus).
40
+ //
41
+ function focusChatInput() {
42
+ document.getElementById('chat-input')?.focus();
43
+ }
44
+ function initPanelChrome() {
45
+ const viewButtons = Array.from(document.querySelectorAll('#panel-view-seg button'));
46
+ const applyView = (view) => {
47
+ document.body.classList.toggle('term-view', view === 'terminal');
48
+ for (const button of viewButtons) {
49
+ button.classList.toggle('active', button.dataset.view === view);
50
+ }
51
+ try {
52
+ localStorage.setItem('castle-panel-view', view);
53
+ }
54
+ catch {
55
+ /* storage disabled */
56
+ }
57
+ };
58
+ document.addEventListener('castle-panel-view', (event) => {
59
+ const detail = event.detail;
60
+ if (!detail)
61
+ return;
62
+ const view = detail.view === 'terminal' ? 'terminal' : 'chat';
63
+ applyView(view);
64
+ if (view === 'chat' && detail.focus)
65
+ focusChatInput();
66
+ });
67
+ for (const button of viewButtons) {
68
+ button.addEventListener('click', () => {
69
+ const view = button.dataset.view === 'terminal' ? 'terminal' : 'chat';
70
+ document.dispatchEvent(new CustomEvent('castle-panel-view', { detail: { view, focus: true } }));
71
+ });
72
+ }
73
+ let storedView = 'chat';
74
+ try {
75
+ storedView = localStorage.getItem('castle-panel-view') === 'terminal' ? 'terminal' : 'chat';
76
+ }
77
+ catch {
78
+ /* storage disabled */
79
+ }
80
+ applyView(storedView);
81
+ // When the panel is toggled open onto the chat view, focus the input.
82
+ // ide-client's own click handler runs first and flips term-open; check the
83
+ // final state a tick later.
84
+ document.getElementById('term-toggle')?.addEventListener('click', () => {
85
+ window.setTimeout(() => {
86
+ if (document.body.classList.contains('term-open') &&
87
+ !document.body.classList.contains('term-view')) {
88
+ focusChatInput();
89
+ }
90
+ }, 0);
91
+ });
92
+ const progressButtons = Array.from(document.querySelectorAll('#chat-progress-seg button'));
93
+ const applyProgressStyle = (style) => {
94
+ document.body.classList.toggle('progress-bars', style === 'bar');
95
+ for (const button of progressButtons) {
96
+ button.classList.toggle('active', button.dataset.progress === style);
97
+ }
98
+ try {
99
+ localStorage.setItem('castle-progress-style', style);
100
+ }
101
+ catch {
102
+ /* storage disabled */
103
+ }
104
+ };
105
+ for (const button of progressButtons) {
106
+ button.addEventListener('click', () => {
107
+ applyProgressStyle(button.dataset.progress === 'bar' ? 'bar' : 'pie');
108
+ });
109
+ }
110
+ let storedStyle = 'pie';
111
+ try {
112
+ storedStyle = localStorage.getItem('castle-progress-style') === 'bar' ? 'bar' : 'pie';
113
+ }
114
+ catch {
115
+ /* storage disabled */
116
+ }
117
+ applyProgressStyle(storedStyle);
118
+ // Backend / model segs: clicks become events the React app forwards to the
119
+ // server (the state lives in .castle/agent/settings.json).
120
+ const serverSegs = [
121
+ ['agent-router-seg', 'router'],
122
+ ['agent-tasks-seg', 'tasks'],
123
+ ['agent-model-seg', 'claudeModel'],
124
+ ];
125
+ for (const [segId, segKey] of serverSegs) {
126
+ for (const button of document.querySelectorAll(`#${segId} button`)) {
127
+ button.addEventListener('click', () => {
128
+ const value = button.dataset.backend ?? button.dataset.model;
129
+ if (!value)
130
+ return;
131
+ document.dispatchEvent(new CustomEvent('castle-set-backend', { detail: { key: segKey, value } }));
132
+ });
133
+ }
134
+ }
135
+ }
136
+ //
137
+ // React app.
138
+ //
139
+ function applyServerEvent(ev, setMessages, setTasks) {
140
+ if (ev.type === 'hello') {
141
+ const messages = ev.messages ?? [];
142
+ const tasks = ev.tasks ?? [];
143
+ setMessages(() => messages);
144
+ setTasks(() => tasks);
145
+ if (ev.settings)
146
+ applyBackendSegs(ev.settings);
147
+ }
148
+ else if (ev.type === 'settings' && ev.settings) {
149
+ applyBackendSegs(ev.settings);
150
+ }
151
+ else if (ev.type === 'message-add' && ev.message) {
152
+ const message = ev.message;
153
+ setMessages((prev) => [...prev, message]);
154
+ }
155
+ else if (ev.type === 'message-delta' && ev.id) {
156
+ setMessages((prev) => prev.map((m) => (m.id === ev.id ? { ...m, text: m.text + (ev.delta ?? '') } : m)));
157
+ }
158
+ else if (ev.type === 'message-activity' && ev.id) {
159
+ setMessages((prev) => prev.map((m) => (m.id === ev.id ? { ...m, activity: ev.activity ?? null } : m)));
160
+ }
161
+ else if (ev.type === 'message-done' && ev.id) {
162
+ setMessages((prev) => prev.map((m) => m.id === ev.id
163
+ ? {
164
+ ...m,
165
+ text: ev.text ?? '',
166
+ status: ev.status ?? 'done',
167
+ activity: null,
168
+ interrupted: ev.interrupted === true,
169
+ }
170
+ : m));
171
+ }
172
+ else if (ev.type === 'task-update' && ev.task) {
173
+ const task = ev.task;
174
+ setTasks((prev) => {
175
+ const known = prev.some((t) => t.id === task.id);
176
+ const next = known ? prev.map((t) => (t.id === task.id ? task : t)) : [...prev, task];
177
+ return next.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
178
+ });
179
+ }
180
+ }
181
+ function TaskRow(props) {
182
+ const { task, onAck } = props;
183
+ const [expanded, setExpanded] = React.useState(false);
184
+ const pct = task.status === 'done' ? 100 : task.progress;
185
+ const finished = TERMINAL_TASK_STATUSES.includes(task.status);
186
+ const notes = task.notes.trim() || task.resultSummary?.trim() || '(no notes from the agent yet)';
187
+ return (React.createElement("div", { className: `task-card${task.status === 'waiting' ? ' waiting' : ''}`, onClick: () => setExpanded(!expanded) },
188
+ React.createElement("div", { className: "task-row" },
189
+ React.createElement("span", { className: `task-pie${finished ? ' task-ack-pie' : ''}`, title: finished ? 'tested -- check off' : undefined, style: { background: `conic-gradient(#0969da ${pct}%, #eaeef2 0)` }, onClick: finished
190
+ ? (event) => {
191
+ event.stopPropagation();
192
+ onAck(task.id, false);
193
+ }
194
+ : undefined }),
195
+ React.createElement("span", { className: "task-title" }, task.title),
196
+ task.status !== 'done' ? (React.createElement("span", { className: `task-status ${task.status}` }, task.status === 'running' ? `${pct}%` : task.status)) : null),
197
+ React.createElement("div", { className: "task-bar" },
198
+ React.createElement("div", { style: { width: `${pct}%` } })),
199
+ expanded ? (React.createElement("div", { className: "task-notes", dangerouslySetInnerHTML: renderMarkdown(notes) })) : null));
200
+ }
201
+ function TaskBoard(props) {
202
+ const visible = props.tasks.filter((t) => !t.acknowledged);
203
+ if (visible.length === 0)
204
+ return null;
205
+ return (React.createElement("div", { id: "chat-strip" }, visible.map((task) => (React.createElement(TaskRow, { key: task.id, task: task, onAck: props.onAck })))));
206
+ }
207
+ function Message(props) {
208
+ const { msg } = props;
209
+ if (msg.role === 'log') {
210
+ return React.createElement("div", { className: "msg msg-log" }, msg.text);
211
+ }
212
+ if (msg.role === 'user') {
213
+ return (React.createElement("div", { className: "msg msg-user" },
214
+ (msg.attachments ?? []).map((name) => (React.createElement("img", { key: name, className: "msg-image", src: `${ATTACHMENT_URL_PREFIX}${name}`, alt: "" }))),
215
+ msg.text ? React.createElement("div", { className: "msg-text" }, msg.text) : null));
216
+ }
217
+ const classes = ['msg', 'msg-assistant'];
218
+ if (msg.status === 'streaming')
219
+ classes.push('streaming');
220
+ if (msg.status === 'error')
221
+ classes.push('msg-error');
222
+ return (React.createElement("div", { className: classes.join(' ') },
223
+ React.createElement("div", { className: "msg-text", dangerouslySetInnerHTML: renderMarkdown(msg.text) }),
224
+ msg.status === 'streaming' && msg.activity ? (React.createElement("div", { className: "msg-activity" },
225
+ msg.activity,
226
+ "...")) : null,
227
+ msg.interrupted ? React.createElement("div", { className: "msg-interrupted" }, "interrupted by your next message") : null));
228
+ }
229
+ function MessageList(props) {
230
+ const hostRef = React.useRef(null);
231
+ // Distance from the bottom of the scroll region -- the value to preserve.
232
+ // The list is bottom-anchored: when the viewport resizes (task board grows
233
+ // or a card expands above), keep the same content at the bottom instead of
234
+ // letting the chat get pushed around.
235
+ const fromBottomRef = React.useRef(0);
236
+ const pinnedRef = React.useRef(true);
237
+ React.useLayoutEffect(() => {
238
+ const host = hostRef.current;
239
+ if (host && pinnedRef.current)
240
+ host.scrollTop = host.scrollHeight;
241
+ }, [props.messages]);
242
+ React.useEffect(() => {
243
+ const host = hostRef.current;
244
+ if (!host)
245
+ return;
246
+ const observer = new ResizeObserver(() => {
247
+ host.scrollTop = host.scrollHeight - host.clientHeight - fromBottomRef.current;
248
+ });
249
+ observer.observe(host);
250
+ return () => observer.disconnect();
251
+ }, []);
252
+ const handleScroll = () => {
253
+ const host = hostRef.current;
254
+ if (!host)
255
+ return;
256
+ fromBottomRef.current = host.scrollHeight - host.scrollTop - host.clientHeight;
257
+ pinnedRef.current = fromBottomRef.current < 48;
258
+ };
259
+ return (React.createElement("div", { id: "chat-messages", ref: hostRef, onScroll: handleScroll },
260
+ props.messages.length === 0 ? (React.createElement("div", { id: "chat-empty" }, "Tell the agent what you want to make.")) : null,
261
+ props.messages.map((msg) => (React.createElement(Message, { key: msg.id, msg: msg })))));
262
+ }
263
+ function readImageFiles(files, add) {
264
+ for (const file of Array.from(files)) {
265
+ if (!file.type.startsWith('image/'))
266
+ continue;
267
+ const reader = new FileReader();
268
+ reader.onload = () => {
269
+ if (typeof reader.result === 'string')
270
+ add({ name: file.name, dataUrl: reader.result });
271
+ };
272
+ reader.readAsDataURL(file);
273
+ }
274
+ }
275
+ function InputRow(props) {
276
+ const [value, setValue] = React.useState('');
277
+ const [pending, setPending] = React.useState([]);
278
+ const inputRef = React.useRef(null);
279
+ const autosize = () => {
280
+ const el = inputRef.current;
281
+ if (!el)
282
+ return;
283
+ el.style.height = 'auto';
284
+ el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
285
+ };
286
+ React.useLayoutEffect(autosize, [value]);
287
+ const addImage = (img) => {
288
+ setPending((prev) => (prev.length >= 6 ? prev : [...prev, img]));
289
+ };
290
+ const send = () => {
291
+ const text = value.trim();
292
+ if (!text && pending.length === 0)
293
+ return;
294
+ props.onSend(text, pending);
295
+ setValue('');
296
+ setPending([]);
297
+ };
298
+ return (React.createElement(React.Fragment, null,
299
+ pending.length > 0 ? (React.createElement("div", { id: "chat-pending" }, pending.map((img, index) => (React.createElement("img", { key: `${img.name}-${index}`, src: img.dataUrl, alt: img.name, title: "remove", onClick: () => setPending((prev) => prev.filter((_, i) => i !== index)) }))))) : null,
300
+ React.createElement("div", { id: "chat-input-row" },
301
+ React.createElement("textarea", { id: "chat-input", ref: inputRef, rows: 1, placeholder: "Message the agent...", value: value, onChange: (event) => setValue(event.target.value), onPaste: (event) => {
302
+ const files = event.clipboardData?.files;
303
+ if (files && files.length > 0) {
304
+ event.preventDefault();
305
+ readImageFiles(files, addImage);
306
+ }
307
+ }, onKeyDown: (event) => {
308
+ if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.altKey) {
309
+ event.preventDefault();
310
+ send();
311
+ }
312
+ } }),
313
+ React.createElement("button", { id: "chat-send", type: "button", tabIndex: -1, onClick: send }, "Send"))));
314
+ }
315
+ function App() {
316
+ const [messages, setMessages] = React.useState([]);
317
+ const [tasks, setTasks] = React.useState([]);
318
+ const sendRef = React.useRef(() => undefined);
319
+ React.useEffect(() => {
320
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
321
+ const wsUrl = `${wsProtocol}//${window.location.host}/__castle/agent`;
322
+ let ws = null;
323
+ let alive = true;
324
+ let retryMs = 500;
325
+ const outbox = [];
326
+ sendRef.current = (payload) => {
327
+ const data = JSON.stringify(payload);
328
+ if (ws && ws.readyState === WebSocket.OPEN)
329
+ ws.send(data);
330
+ else
331
+ outbox.push(data);
332
+ };
333
+ const connect = () => {
334
+ if (!alive || document.visibilityState === 'hidden')
335
+ return;
336
+ const sock = new WebSocket(wsUrl);
337
+ ws = sock;
338
+ sock.addEventListener('open', () => {
339
+ retryMs = 500;
340
+ while (outbox.length > 0 && sock.readyState === sock.OPEN) {
341
+ const queued = outbox.shift();
342
+ if (queued)
343
+ sock.send(queued);
344
+ }
345
+ });
346
+ sock.addEventListener('message', (event) => {
347
+ try {
348
+ applyServerEvent(JSON.parse(String(event.data)), setMessages, setTasks);
349
+ }
350
+ catch {
351
+ /* malformed frame */
352
+ }
353
+ });
354
+ sock.addEventListener('close', () => {
355
+ if (ws !== sock)
356
+ return;
357
+ ws = null;
358
+ if (!alive)
359
+ return;
360
+ window.setTimeout(connect, retryMs);
361
+ retryMs = Math.min(retryMs * 2, 15000);
362
+ });
363
+ };
364
+ const onVisibility = () => {
365
+ if (document.visibilityState !== 'hidden' && !ws)
366
+ connect();
367
+ };
368
+ const onSetBackend = (event) => {
369
+ const detail = event.detail;
370
+ if (!detail?.key || !detail.value)
371
+ return;
372
+ sendRef.current({ type: 'set-settings', [detail.key]: detail.value });
373
+ };
374
+ document.addEventListener('visibilitychange', onVisibility);
375
+ document.addEventListener('castle-set-backend', onSetBackend);
376
+ connect();
377
+ return () => {
378
+ alive = false;
379
+ document.removeEventListener('visibilitychange', onVisibility);
380
+ document.removeEventListener('castle-set-backend', onSetBackend);
381
+ ws?.close();
382
+ };
383
+ }, []);
384
+ return (React.createElement(React.Fragment, null,
385
+ React.createElement(TaskBoard, { tasks: tasks, onAck: (id, rejected) => sendRef.current({ type: 'task-ack', id, rejected }) }),
386
+ React.createElement(MessageList, { messages: messages }),
387
+ React.createElement(InputRow, { onSend: (text, images) => sendRef.current({ type: 'user-message', text, images }) })));
388
+ }
389
+ initPanelChrome();
390
+ const chatHost = document.getElementById('chat-host');
391
+ if (chatHost) {
392
+ ReactDOM.createRoot(chatHost).render(React.createElement(App, null));
393
+ if (document.body.classList.contains('term-open') &&
394
+ !document.body.classList.contains('term-view')) {
395
+ window.setTimeout(focusChatInput, 0);
396
+ }
397
+ }
398
+ export {};
@@ -0,0 +1 @@
1
+ export declare const COMMON_INSTRUCTIONS = "## Touch controls (every deck)\n\n- **Build for touch from the start.** Every deck must be fully playable on a touchscreen with no physical keyboard \u2014 design the touch controls first, not as an afterthought. Keyboard input is still fine on top, for shortcuts or typing. A touch control that only mirrors keys (e.g. a d-pad over the arrow keys) may hide itself when a hardware keyboard is present. Use good judgement.\n";
@@ -0,0 +1,8 @@
1
+ // Common agent guidance appended by `castle-web init` to every scaffolded
2
+ // deck's CLAUDE.md, regardless of kit (or no kit). Single source of truth —
3
+ // edit here, not in the kits. Keep it truly kit-agnostic; kit-specific rules
4
+ // (e.g. Space being reserved for play/stop) live in each kit's own CLAUDE.md.
5
+ export const COMMON_INSTRUCTIONS = `## Touch controls (every deck)
6
+
7
+ - **Build for touch from the start.** Every deck must be fully playable on a touchscreen with no physical keyboard — design the touch controls first, not as an afterthought. Keyboard input is still fine on top, for shortcuts or typing. A touch control that only mirrors keys (e.g. a d-pad over the arrow keys) may hide itself when a hardware keyboard is present. Use good judgement.
8
+ `;
@@ -124,11 +124,13 @@
124
124
  //
125
125
  const openStorageKey = 'castle-terminal-open';
126
126
  function storedOpen() {
127
+ // The panel (chat by default) is open unless the user closed it.
127
128
  try {
128
- return localStorage.getItem(openStorageKey) === '1';
129
+ const value = localStorage.getItem(openStorageKey);
130
+ return value === null ? true : value === '1';
129
131
  }
130
132
  catch {
131
- return false;
133
+ return true;
132
134
  }
133
135
  }
134
136
  function persistOpen(open) {
@@ -159,10 +161,27 @@
159
161
  document.body.classList.remove('term-settings-open');
160
162
  return;
161
163
  }
162
- activateTerminal();
163
- term.focus();
164
+ // Only the terminal view needs the PTY; the chat client handles its own
165
+ // focus when the panel opens onto the chat view.
166
+ if (document.body.classList.contains('term-view')) {
167
+ activateTerminal();
168
+ term.focus();
169
+ }
164
170
  });
165
171
  }
172
+ // The chat client owns the chat | terminal view switch and dispatches
173
+ // `castle-panel-view`; the terminal side reacts here (lazy PTY spawn + fit).
174
+ document.addEventListener('castle-panel-view', (event) => {
175
+ const detail = event.detail;
176
+ if (!detail || detail.view !== 'terminal')
177
+ return;
178
+ if (!document.body.classList.contains('term-open'))
179
+ return;
180
+ activateTerminal();
181
+ fitAndSendResize();
182
+ if (detail.focus)
183
+ term.focus();
184
+ });
166
185
  //
167
186
  // Settings cog: a small popover with a light/dark theme switch for the
168
187
  // terminal. The choice persists in localStorage.
@@ -233,13 +252,16 @@
233
252
  /* storage disabled -- focus side just won't persist */
234
253
  }
235
254
  if (target === 'terminal') {
236
- // Focusing the terminal implies revealing it.
255
+ // Focusing the panel implies revealing it. Focus lands on whichever
256
+ // view the panel is showing -- the chat input or the terminal. The
257
+ // chat client flips classes / focuses its input on this event; the
258
+ // listener above does the PTY activation + focus for the terminal.
237
259
  if (!document.body.classList.contains('term-open')) {
238
260
  document.body.classList.add('term-open');
239
261
  persistOpen(true);
240
- activateTerminal();
241
262
  }
242
- term.focus();
263
+ const view = document.body.classList.contains('term-view') ? 'terminal' : 'chat';
264
+ document.dispatchEvent(new CustomEvent('castle-panel-view', { detail: { view, focus: true } }));
243
265
  }
244
266
  else {
245
267
  deckFrame?.contentWindow?.focus();
@@ -248,7 +270,7 @@
248
270
  function toggleFocus() {
249
271
  setFocus(focusTarget === 'terminal' ? 'deck' : 'terminal');
250
272
  }
251
- // Ctrl+T while the parent (terminal or shell chrome) holds focus. Capture
273
+ // Ctrl+T while the parent (panel or shell chrome) holds focus. Capture
252
274
  // phase + stopImmediatePropagation so xterm never receives the keystroke.
253
275
  document.addEventListener('keydown', (event) => {
254
276
  if (event.ctrlKey &&
@@ -269,12 +291,16 @@
269
291
  return;
270
292
  toggleFocus();
271
293
  });
272
- // Keep focusTarget honest when focus moves by click rather than Ctrl+T.
294
+ // Keep focusTarget honest when focus moves by click rather than Tab/Ctrl+T.
273
295
  if (term.textarea) {
274
296
  term.textarea.addEventListener('focus', () => {
275
297
  focusTarget = 'terminal';
276
298
  });
277
299
  }
300
+ document.addEventListener('focusin', (event) => {
301
+ if (event.target?.id === 'chat-input')
302
+ focusTarget = 'terminal';
303
+ });
278
304
  window.addEventListener('blur', () => {
279
305
  if (deckFrame && document.activeElement === deckFrame)
280
306
  focusTarget = 'deck';
@@ -510,10 +536,14 @@
510
536
  }, { passive: false });
511
537
  const observer = new ResizeObserver(() => fitAndSendResize());
512
538
  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');
539
+ // Restore the persisted open state. The server renders the panel open (the
540
+ // body ships with `term-open`); drop it if the user had closed it. The chat
541
+ // client runs first and restores the view class, so a terminal-view restore
542
+ // can spawn its PTY right away.
543
+ if (!storedOpen()) {
544
+ document.body.classList.remove('term-open');
545
+ }
546
+ else if (document.body.classList.contains('term-view')) {
517
547
  activateTerminal();
518
548
  }
519
549
  // Restore the persisted focus side. xterm needs a layout pass after
@@ -522,7 +552,9 @@
522
552
  // fails. Defer the terminal-focus restore behind a fit + two animation
523
553
  // frames so the grid has settled first (matching threads' post-layout
524
554
  // focus sequencing).
525
- if (storedFocus() === 'terminal' && document.body.classList.contains('term-open')) {
555
+ if (storedFocus() === 'terminal' &&
556
+ document.body.classList.contains('term-open') &&
557
+ document.body.classList.contains('term-view')) {
526
558
  window.requestAnimationFrame(() => {
527
559
  fitNow();
528
560
  window.requestAnimationFrame(() => {
package/dist/ide.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import * as http from 'http';
2
2
  import { Duplex } from 'stream';
3
+ import { type RawData } from 'ws';
3
4
  export declare const IDE_ASSET_PREFIX = "/__castle/ide/";
4
5
  export declare const PTY_WS_PATH = "/__castle/pty";
5
6
  export declare const DECK_FOCUS_SCRIPT: string;
7
+ export declare function rawDataToString(data: RawData): string;
6
8
  export interface IdeServer {
7
9
  /** Serve the IDE page + its static assets. Returns true if it handled the request. */
8
10
  handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse, reqPath: string): boolean;