castle-web-cli 0.4.11 → 0.4.13
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/dist/agent-prompts.d.ts +31 -0
- package/dist/agent-prompts.js +104 -0
- package/dist/agent.d.ts +17 -0
- package/dist/agent.js +952 -0
- package/dist/chat-client.d.ts +1 -0
- package/dist/chat-client.js +425 -0
- package/dist/commonInstructions.d.ts +1 -0
- package/dist/commonInstructions.js +8 -0
- package/dist/ide-client.js +46 -14
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +348 -36
- package/dist/init.js +12 -1
- package/dist/serve.js +18 -3
- package/kits/basic-2d/CLAUDE.md +3 -1
- package/kits/basic-3d/.prettierrc +8 -0
- package/kits/basic-3d/CLAUDE.md +162 -0
- package/kits/basic-3d/behaviors/Camera.jsx +56 -0
- package/kits/basic-3d/behaviors/Collider.jsx +78 -0
- package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
- package/kits/basic-3d/behaviors/Model.jsx +61 -0
- package/kits/basic-3d/behaviors/Transform.jsx +35 -0
- package/kits/basic-3d/editors/App.jsx +147 -0
- package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
- package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
- package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-3d/editors/editorHistory.js +52 -0
- package/kits/basic-3d/editors/viewportRig.js +90 -0
- package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
- package/kits/basic-3d/engine/SceneUI.jsx +67 -0
- package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
- package/kits/basic-3d/engine/TouchControls.jsx +136 -0
- package/kits/basic-3d/engine/autoInspector.jsx +51 -0
- package/kits/basic-3d/engine/files.js +73 -0
- package/kits/basic-3d/engine/scene.js +502 -0
- package/kits/basic-3d/engine/threeUtil.js +260 -0
- package/kits/basic-3d/engine/ui.jsx +352 -0
- package/kits/basic-3d/engine/ui.module.css +944 -0
- package/kits/basic-3d/eslint.config.js +51 -0
- package/kits/basic-3d/index.html +11 -0
- package/kits/basic-3d/main.jsx +10 -0
- package/kits/basic-3d/models/block.model +14 -0
- package/kits/basic-3d/package-lock.json +2713 -0
- package/kits/basic-3d/package.json +41 -0
- package/kits/basic-3d/scenes/main.scene +76 -0
- package/kits/basic-3d/vite.config.js +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,425 @@
|
|
|
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, setFeeds) {
|
|
140
|
+
if (ev.type === 'hello') {
|
|
141
|
+
const messages = ev.messages ?? [];
|
|
142
|
+
const tasks = ev.tasks ?? [];
|
|
143
|
+
const feeds = ev.feeds ?? {};
|
|
144
|
+
setMessages(() => messages);
|
|
145
|
+
setTasks(() => tasks);
|
|
146
|
+
setFeeds(() => feeds);
|
|
147
|
+
if (ev.settings)
|
|
148
|
+
applyBackendSegs(ev.settings);
|
|
149
|
+
}
|
|
150
|
+
else if (ev.type === 'task-feed' && ev.id) {
|
|
151
|
+
const id = ev.id;
|
|
152
|
+
const entry = ev.entry ?? '';
|
|
153
|
+
setFeeds((prev) => ({ ...prev, [id]: [...(prev[id] ?? []).slice(-79), entry] }));
|
|
154
|
+
}
|
|
155
|
+
else if (ev.type === 'settings' && ev.settings) {
|
|
156
|
+
applyBackendSegs(ev.settings);
|
|
157
|
+
}
|
|
158
|
+
else if (ev.type === 'message-add' && ev.message) {
|
|
159
|
+
const message = ev.message;
|
|
160
|
+
setMessages((prev) => [...prev, message]);
|
|
161
|
+
}
|
|
162
|
+
else if (ev.type === 'message-delta' && ev.id) {
|
|
163
|
+
setMessages((prev) => prev.map((m) => (m.id === ev.id ? { ...m, text: m.text + (ev.delta ?? '') } : m)));
|
|
164
|
+
}
|
|
165
|
+
else if (ev.type === 'message-activity' && ev.id) {
|
|
166
|
+
setMessages((prev) => prev.map((m) => (m.id === ev.id ? { ...m, activity: ev.activity ?? null } : m)));
|
|
167
|
+
}
|
|
168
|
+
else if (ev.type === 'message-done' && ev.id) {
|
|
169
|
+
setMessages((prev) => prev.map((m) => m.id === ev.id
|
|
170
|
+
? {
|
|
171
|
+
...m,
|
|
172
|
+
text: ev.text ?? '',
|
|
173
|
+
status: ev.status ?? 'done',
|
|
174
|
+
activity: null,
|
|
175
|
+
interrupted: ev.interrupted === true,
|
|
176
|
+
}
|
|
177
|
+
: m));
|
|
178
|
+
}
|
|
179
|
+
else if (ev.type === 'task-update' && ev.task) {
|
|
180
|
+
const task = ev.task;
|
|
181
|
+
setTasks((prev) => {
|
|
182
|
+
const known = prev.some((t) => t.id === task.id);
|
|
183
|
+
const next = known ? prev.map((t) => (t.id === task.id ? task : t)) : [...prev, task];
|
|
184
|
+
return next.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
185
|
+
});
|
|
186
|
+
if (TERMINAL_TASK_STATUSES.includes(task.status)) {
|
|
187
|
+
setFeeds((prev) => {
|
|
188
|
+
if (!(task.id in prev))
|
|
189
|
+
return prev;
|
|
190
|
+
const next = { ...prev };
|
|
191
|
+
delete next[task.id];
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function TaskFeed(props) {
|
|
198
|
+
const hostRef = React.useRef(null);
|
|
199
|
+
React.useLayoutEffect(() => {
|
|
200
|
+
const host = hostRef.current;
|
|
201
|
+
if (host)
|
|
202
|
+
host.scrollTop = host.scrollHeight;
|
|
203
|
+
}, [props.lines]);
|
|
204
|
+
return (React.createElement("div", { className: "task-feed", ref: hostRef, onClick: (event) => event.stopPropagation() }, props.lines.map((line, index) => (React.createElement("div", { key: index }, line)))));
|
|
205
|
+
}
|
|
206
|
+
function TaskRow(props) {
|
|
207
|
+
const { task, onAck } = props;
|
|
208
|
+
const [expanded, setExpanded] = React.useState(false);
|
|
209
|
+
const pct = task.status === 'done' ? 100 : task.progress;
|
|
210
|
+
const finished = TERMINAL_TASK_STATUSES.includes(task.status);
|
|
211
|
+
const notes = task.notes.trim() || task.resultSummary?.trim() || '(no notes from the agent yet)';
|
|
212
|
+
return (React.createElement("div", { className: `task-card${task.status === 'waiting' ? ' waiting' : ''}`, onClick: () => setExpanded(!expanded) },
|
|
213
|
+
React.createElement("div", { className: "task-row" },
|
|
214
|
+
React.createElement("span", { className: `task-pie${finished ? ' task-ack-pie' : ''}`, title: finished ? 'tested -- check off' : undefined, style: { background: `conic-gradient(#000000 ${pct}%, #eaeef2 0)` }, onClick: finished
|
|
215
|
+
? (event) => {
|
|
216
|
+
event.stopPropagation();
|
|
217
|
+
onAck(task.id, false);
|
|
218
|
+
}
|
|
219
|
+
: undefined }),
|
|
220
|
+
React.createElement("span", { className: "task-title" }, task.title),
|
|
221
|
+
task.status !== 'done' ? (React.createElement("span", { className: `task-status ${task.status}` }, task.status === 'running' ? `${pct}%` : task.status)) : null),
|
|
222
|
+
React.createElement("div", { className: "task-bar" },
|
|
223
|
+
React.createElement("div", { style: { width: `${pct}%` } })),
|
|
224
|
+
expanded && task.status === 'running' && props.feed && props.feed.length > 0 ? (React.createElement(TaskFeed, { lines: props.feed })) : null,
|
|
225
|
+
expanded && task.status !== 'running' ? (React.createElement("div", { className: "task-notes", dangerouslySetInnerHTML: renderMarkdown(notes) })) : null));
|
|
226
|
+
}
|
|
227
|
+
function TaskBoard(props) {
|
|
228
|
+
const visible = props.tasks.filter((t) => !t.acknowledged);
|
|
229
|
+
if (visible.length === 0)
|
|
230
|
+
return null;
|
|
231
|
+
return (React.createElement("div", { id: "chat-strip" }, visible.map((task) => (React.createElement(TaskRow, { key: task.id, task: task, feed: props.feeds[task.id], onAck: props.onAck })))));
|
|
232
|
+
}
|
|
233
|
+
function Message(props) {
|
|
234
|
+
const { msg } = props;
|
|
235
|
+
if (msg.role === 'log') {
|
|
236
|
+
return React.createElement("div", { className: "msg msg-log" }, msg.text);
|
|
237
|
+
}
|
|
238
|
+
if (msg.role === 'user') {
|
|
239
|
+
return (React.createElement("div", { className: "msg msg-user" },
|
|
240
|
+
(msg.attachments ?? []).map((name) => (React.createElement("img", { key: name, className: "msg-image", src: `${ATTACHMENT_URL_PREFIX}${name}`, alt: "" }))),
|
|
241
|
+
msg.text ? React.createElement("div", { className: "msg-text" }, msg.text) : null));
|
|
242
|
+
}
|
|
243
|
+
const classes = ['msg', 'msg-assistant'];
|
|
244
|
+
if (msg.status === 'streaming')
|
|
245
|
+
classes.push('streaming');
|
|
246
|
+
if (msg.status === 'error')
|
|
247
|
+
classes.push('msg-error');
|
|
248
|
+
return (React.createElement("div", { className: classes.join(' ') },
|
|
249
|
+
React.createElement("div", { className: "msg-text", dangerouslySetInnerHTML: renderMarkdown(msg.text) }),
|
|
250
|
+
msg.status === 'streaming' && msg.activity ? (React.createElement("div", { className: "msg-activity" },
|
|
251
|
+
msg.activity,
|
|
252
|
+
"...")) : null,
|
|
253
|
+
msg.interrupted ? React.createElement("div", { className: "msg-interrupted" }, "interrupted by your next message") : null));
|
|
254
|
+
}
|
|
255
|
+
function MessageList(props) {
|
|
256
|
+
const hostRef = React.useRef(null);
|
|
257
|
+
// Distance from the bottom of the scroll region -- the value to preserve.
|
|
258
|
+
// The list is bottom-anchored: when the viewport resizes (task board grows
|
|
259
|
+
// or a card expands above), keep the same content at the bottom instead of
|
|
260
|
+
// letting the chat get pushed around.
|
|
261
|
+
const fromBottomRef = React.useRef(0);
|
|
262
|
+
const pinnedRef = React.useRef(true);
|
|
263
|
+
React.useLayoutEffect(() => {
|
|
264
|
+
const host = hostRef.current;
|
|
265
|
+
if (host && pinnedRef.current)
|
|
266
|
+
host.scrollTop = host.scrollHeight;
|
|
267
|
+
}, [props.messages]);
|
|
268
|
+
React.useEffect(() => {
|
|
269
|
+
const host = hostRef.current;
|
|
270
|
+
if (!host)
|
|
271
|
+
return;
|
|
272
|
+
const observer = new ResizeObserver(() => {
|
|
273
|
+
host.scrollTop = host.scrollHeight - host.clientHeight - fromBottomRef.current;
|
|
274
|
+
});
|
|
275
|
+
observer.observe(host);
|
|
276
|
+
return () => observer.disconnect();
|
|
277
|
+
}, []);
|
|
278
|
+
const handleScroll = () => {
|
|
279
|
+
const host = hostRef.current;
|
|
280
|
+
if (!host)
|
|
281
|
+
return;
|
|
282
|
+
fromBottomRef.current = host.scrollHeight - host.scrollTop - host.clientHeight;
|
|
283
|
+
pinnedRef.current = fromBottomRef.current < 48;
|
|
284
|
+
};
|
|
285
|
+
return (React.createElement("div", { id: "chat-messages", ref: hostRef, onScroll: handleScroll },
|
|
286
|
+
props.messages.length === 0 ? (React.createElement("div", { id: "chat-empty" }, "Tell the agent what you want to make.")) : null,
|
|
287
|
+
props.messages.map((msg) => (React.createElement(Message, { key: msg.id, msg: msg })))));
|
|
288
|
+
}
|
|
289
|
+
function readImageFiles(files, add) {
|
|
290
|
+
for (const file of Array.from(files)) {
|
|
291
|
+
if (!file.type.startsWith('image/'))
|
|
292
|
+
continue;
|
|
293
|
+
const reader = new FileReader();
|
|
294
|
+
reader.onload = () => {
|
|
295
|
+
if (typeof reader.result === 'string')
|
|
296
|
+
add({ name: file.name, dataUrl: reader.result });
|
|
297
|
+
};
|
|
298
|
+
reader.readAsDataURL(file);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function InputRow(props) {
|
|
302
|
+
const [value, setValue] = React.useState('');
|
|
303
|
+
const [pending, setPending] = React.useState([]);
|
|
304
|
+
const inputRef = React.useRef(null);
|
|
305
|
+
const autosize = () => {
|
|
306
|
+
const el = inputRef.current;
|
|
307
|
+
if (!el)
|
|
308
|
+
return;
|
|
309
|
+
el.style.height = 'auto';
|
|
310
|
+
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
|
311
|
+
};
|
|
312
|
+
React.useLayoutEffect(autosize, [value]);
|
|
313
|
+
const addImage = (img) => {
|
|
314
|
+
setPending((prev) => (prev.length >= 6 ? prev : [...prev, img]));
|
|
315
|
+
};
|
|
316
|
+
const send = () => {
|
|
317
|
+
const text = value.trim();
|
|
318
|
+
if (!text && pending.length === 0)
|
|
319
|
+
return;
|
|
320
|
+
props.onSend(text, pending);
|
|
321
|
+
setValue('');
|
|
322
|
+
setPending([]);
|
|
323
|
+
};
|
|
324
|
+
return (React.createElement(React.Fragment, null,
|
|
325
|
+
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,
|
|
326
|
+
React.createElement("div", { id: "chat-input-row" },
|
|
327
|
+
React.createElement("textarea", { id: "chat-input", ref: inputRef, rows: 1, placeholder: "Message the agent...", value: value, onChange: (event) => setValue(event.target.value), onPaste: (event) => {
|
|
328
|
+
const files = event.clipboardData?.files;
|
|
329
|
+
if (files && files.length > 0) {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
readImageFiles(files, addImage);
|
|
332
|
+
}
|
|
333
|
+
}, onKeyDown: (event) => {
|
|
334
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.altKey) {
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
send();
|
|
337
|
+
}
|
|
338
|
+
} }),
|
|
339
|
+
React.createElement("button", { id: "chat-send", type: "button", tabIndex: -1, onClick: send }, "Send"))));
|
|
340
|
+
}
|
|
341
|
+
function App() {
|
|
342
|
+
const [messages, setMessages] = React.useState([]);
|
|
343
|
+
const [tasks, setTasks] = React.useState([]);
|
|
344
|
+
const [feeds, setFeeds] = React.useState({});
|
|
345
|
+
const sendRef = React.useRef(() => undefined);
|
|
346
|
+
React.useEffect(() => {
|
|
347
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
348
|
+
const wsUrl = `${wsProtocol}//${window.location.host}/__castle/agent`;
|
|
349
|
+
let ws = null;
|
|
350
|
+
let alive = true;
|
|
351
|
+
let retryMs = 500;
|
|
352
|
+
const outbox = [];
|
|
353
|
+
sendRef.current = (payload) => {
|
|
354
|
+
const data = JSON.stringify(payload);
|
|
355
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
356
|
+
ws.send(data);
|
|
357
|
+
else
|
|
358
|
+
outbox.push(data);
|
|
359
|
+
};
|
|
360
|
+
const connect = () => {
|
|
361
|
+
if (!alive || document.visibilityState === 'hidden')
|
|
362
|
+
return;
|
|
363
|
+
const sock = new WebSocket(wsUrl);
|
|
364
|
+
ws = sock;
|
|
365
|
+
sock.addEventListener('open', () => {
|
|
366
|
+
retryMs = 500;
|
|
367
|
+
while (outbox.length > 0 && sock.readyState === sock.OPEN) {
|
|
368
|
+
const queued = outbox.shift();
|
|
369
|
+
if (queued)
|
|
370
|
+
sock.send(queued);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
sock.addEventListener('message', (event) => {
|
|
374
|
+
try {
|
|
375
|
+
applyServerEvent(JSON.parse(String(event.data)), setMessages, setTasks, setFeeds);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
/* malformed frame */
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
sock.addEventListener('close', () => {
|
|
382
|
+
if (ws !== sock)
|
|
383
|
+
return;
|
|
384
|
+
ws = null;
|
|
385
|
+
if (!alive)
|
|
386
|
+
return;
|
|
387
|
+
window.setTimeout(connect, retryMs);
|
|
388
|
+
retryMs = Math.min(retryMs * 2, 15000);
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
const onVisibility = () => {
|
|
392
|
+
if (document.visibilityState !== 'hidden' && !ws)
|
|
393
|
+
connect();
|
|
394
|
+
};
|
|
395
|
+
const onSetBackend = (event) => {
|
|
396
|
+
const detail = event.detail;
|
|
397
|
+
if (!detail?.key || !detail.value)
|
|
398
|
+
return;
|
|
399
|
+
sendRef.current({ type: 'set-settings', [detail.key]: detail.value });
|
|
400
|
+
};
|
|
401
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
402
|
+
document.addEventListener('castle-set-backend', onSetBackend);
|
|
403
|
+
connect();
|
|
404
|
+
return () => {
|
|
405
|
+
alive = false;
|
|
406
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
407
|
+
document.removeEventListener('castle-set-backend', onSetBackend);
|
|
408
|
+
ws?.close();
|
|
409
|
+
};
|
|
410
|
+
}, []);
|
|
411
|
+
return (React.createElement(React.Fragment, null,
|
|
412
|
+
React.createElement(TaskBoard, { tasks: tasks, feeds: feeds, onAck: (id, rejected) => sendRef.current({ type: 'task-ack', id, rejected }) }),
|
|
413
|
+
React.createElement(MessageList, { messages: messages }),
|
|
414
|
+
React.createElement(InputRow, { onSend: (text, images) => sendRef.current({ type: 'user-message', text, images }) })));
|
|
415
|
+
}
|
|
416
|
+
initPanelChrome();
|
|
417
|
+
const chatHost = document.getElementById('chat-host');
|
|
418
|
+
if (chatHost) {
|
|
419
|
+
ReactDOM.createRoot(chatHost).render(React.createElement(App, null));
|
|
420
|
+
if (document.body.classList.contains('term-open') &&
|
|
421
|
+
!document.body.classList.contains('term-view')) {
|
|
422
|
+
window.setTimeout(focusChatInput, 0);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
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
|
+
`;
|
package/dist/ide-client.js
CHANGED
|
@@ -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
|
-
|
|
129
|
+
const value = localStorage.getItem(openStorageKey);
|
|
130
|
+
return value === null ? true : value === '1';
|
|
129
131
|
}
|
|
130
132
|
catch {
|
|
131
|
-
return
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
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' &&
|
|
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;
|