castle-web-cli 0.4.63 → 0.4.65

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.
@@ -1,538 +0,0 @@
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
60
- .detail;
61
- if (!detail)
62
- return;
63
- const view = detail.view === "terminal" ? "terminal" : "chat";
64
- applyView(view);
65
- if (view === "chat" && detail.focus)
66
- focusChatInput();
67
- });
68
- for (const button of viewButtons) {
69
- button.addEventListener("click", () => {
70
- const view = button.dataset.view === "terminal" ? "terminal" : "chat";
71
- document.dispatchEvent(new CustomEvent("castle-panel-view", { detail: { view, focus: true } }));
72
- });
73
- }
74
- let storedView = "chat";
75
- try {
76
- storedView =
77
- localStorage.getItem("castle-panel-view") === "terminal"
78
- ? "terminal"
79
- : "chat";
80
- }
81
- catch {
82
- /* storage disabled */
83
- }
84
- applyView(storedView);
85
- // When the panel is toggled open onto the chat view, focus the input.
86
- // ide-client's own click handler runs first and flips term-open; check the
87
- // final state a tick later.
88
- document.getElementById("term-toggle")?.addEventListener("click", () => {
89
- window.setTimeout(() => {
90
- if (document.body.classList.contains("term-open") &&
91
- !document.body.classList.contains("term-view")) {
92
- focusChatInput();
93
- }
94
- }, 0);
95
- });
96
- const progressButtons = Array.from(document.querySelectorAll("#chat-progress-seg button"));
97
- const applyProgressStyle = (style) => {
98
- document.body.classList.toggle("progress-bars", style === "bar");
99
- for (const button of progressButtons) {
100
- button.classList.toggle("active", button.dataset.progress === style);
101
- }
102
- try {
103
- localStorage.setItem("castle-progress-style", style);
104
- }
105
- catch {
106
- /* storage disabled */
107
- }
108
- };
109
- for (const button of progressButtons) {
110
- button.addEventListener("click", () => {
111
- applyProgressStyle(button.dataset.progress === "bar" ? "bar" : "pie");
112
- });
113
- }
114
- let storedStyle = "pie";
115
- try {
116
- storedStyle =
117
- localStorage.getItem("castle-progress-style") === "bar" ? "bar" : "pie";
118
- }
119
- catch {
120
- /* storage disabled */
121
- }
122
- applyProgressStyle(storedStyle);
123
- // Backend / model segs: clicks become events the React app forwards to the
124
- // server (the state lives in .castle/agent/settings.json).
125
- const serverSegs = [
126
- ["agent-router-seg", "router"],
127
- ["agent-tasks-seg", "tasks"],
128
- ["agent-model-seg", "claudeModel"],
129
- ];
130
- for (const [segId, segKey] of serverSegs) {
131
- for (const button of document.querySelectorAll(`#${segId} button`)) {
132
- button.addEventListener("click", () => {
133
- const value = button.dataset.backend ?? button.dataset.model;
134
- if (!value)
135
- return;
136
- document.dispatchEvent(new CustomEvent("castle-set-backend", {
137
- detail: { key: segKey, value },
138
- }));
139
- });
140
- }
141
- }
142
- }
143
- //
144
- // React app.
145
- //
146
- function applyServerEvent(ev, setMessages, setTasks, setFeeds) {
147
- if (ev.type === "hello") {
148
- const messages = ev.messages ?? [];
149
- const tasks = ev.tasks ?? [];
150
- const feeds = ev.feeds ?? {};
151
- setMessages(() => messages);
152
- setTasks(() => tasks);
153
- setFeeds(() => feeds);
154
- if (ev.settings)
155
- applyBackendSegs(ev.settings);
156
- }
157
- else if (ev.type === "task-feed" && ev.id) {
158
- const id = ev.id;
159
- const entry = ev.entry ?? "";
160
- setFeeds((prev) => ({
161
- ...prev,
162
- [id]: [...(prev[id] ?? []).slice(-79), entry],
163
- }));
164
- }
165
- else if (ev.type === "settings" && ev.settings) {
166
- applyBackendSegs(ev.settings);
167
- }
168
- else if (ev.type === "message-add" && ev.message) {
169
- const message = ev.message;
170
- setMessages((prev) => [...prev, message]);
171
- }
172
- else if (ev.type === "message-delta" && ev.id) {
173
- setMessages((prev) => prev.map((m) => m.id === ev.id ? { ...m, text: m.text + (ev.delta ?? "") } : m));
174
- }
175
- else if (ev.type === "message-activity" && ev.id) {
176
- setMessages((prev) => prev.map((m) => m.id === ev.id ? { ...m, activity: ev.activity ?? null } : m));
177
- }
178
- else if (ev.type === "message-done" && ev.id) {
179
- setMessages((prev) => prev.map((m) => m.id === ev.id
180
- ? {
181
- ...m,
182
- text: ev.text ?? "",
183
- status: ev.status ?? "done",
184
- activity: null,
185
- interrupted: ev.interrupted === true,
186
- }
187
- : m));
188
- }
189
- else if (ev.type === "task-update" && ev.task) {
190
- const task = ev.task;
191
- setTasks((prev) => {
192
- const known = prev.some((t) => t.id === task.id);
193
- const next = known
194
- ? prev.map((t) => (t.id === task.id ? task : t))
195
- : [...prev, task];
196
- return next.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
197
- });
198
- if (TERMINAL_TASK_STATUSES.includes(task.status)) {
199
- setFeeds((prev) => {
200
- if (!(task.id in prev))
201
- return prev;
202
- const next = { ...prev };
203
- delete next[task.id];
204
- return next;
205
- });
206
- }
207
- }
208
- }
209
- // Drop "[Reading X]" lines whose file is edited anywhere -- a read that's later
210
- // followed by an edit of the same file is redundant; just show the edit.
211
- function rollupFeed(lines) {
212
- const editRe = /^\[Editing (.+)\]$/;
213
- const readRe = /^\[Reading (.+)\]$/;
214
- const edited = new Set();
215
- for (const line of lines) {
216
- const m = editRe.exec(line.trim());
217
- if (m)
218
- for (const f of m[1].split(", "))
219
- edited.add(f);
220
- }
221
- const out = [];
222
- for (const line of lines) {
223
- const r = readRe.exec(line.trim());
224
- if (r) {
225
- const keep = r[1].split(", ").filter((f) => !edited.has(f));
226
- if (keep.length === 0)
227
- continue;
228
- out.push(`[Reading ${keep.join(", ")}]`);
229
- continue;
230
- }
231
- out.push(line);
232
- }
233
- return out;
234
- }
235
- // Collapse consecutive same-verb tool actions into one comma-separated line:
236
- // "[Editing A]" then "[Editing B]" -> "[Editing A, B]". Only word verbs merge
237
- // (Editing/Reading/...), so prose stays separate.
238
- function coalesceFeed(lines) {
239
- const toolRe = /^\[([A-Za-z]+)\s+(.+)\]$/;
240
- const out = [];
241
- for (const line of lines) {
242
- const m = toolRe.exec(line.trim());
243
- if (m) {
244
- const prev = out.length ? toolRe.exec(out[out.length - 1].trim()) : null;
245
- if (prev && prev[1] === m[1]) {
246
- if (!prev[2].split(", ").includes(m[2])) {
247
- out[out.length - 1] = `[${m[1]} ${prev[2]}, ${m[2]}]`;
248
- }
249
- continue;
250
- }
251
- }
252
- out.push(line);
253
- }
254
- return out;
255
- }
256
- function TaskFeed(props) {
257
- const hostRef = React.useRef(null);
258
- React.useLayoutEffect(() => {
259
- const host = hostRef.current;
260
- if (host)
261
- host.scrollTop = host.scrollHeight;
262
- }, [props.lines]);
263
- return (React.createElement("div", { className: "task-feed", ref: hostRef, onClick: (event) => event.stopPropagation() }, coalesceFeed(rollupFeed(props.lines)).map((line, index) => {
264
- // Tool actions arrive bracketed (e.g. "[Editing Ball.jsx]"); render
265
- // those as indented monospace. Everything else is the agent's prose --
266
- // markdown-render it so **bold** / `code` show through.
267
- const tool = /^\[(.+)\]$/.exec(line.trim());
268
- if (tool) {
269
- return (React.createElement("div", { key: index, className: "task-feed-tool" }, tool[1]));
270
- }
271
- return (React.createElement("div", { key: index, className: "task-feed-msg", dangerouslySetInnerHTML: renderMarkdown(line) }));
272
- })));
273
- }
274
- // Stopwatch label: "7m 23s" (or just "23s" under a minute). Counts whole seconds.
275
- function fmtDuration(ms) {
276
- const total = Math.max(0, Math.round(ms / 1000));
277
- const m = Math.floor(total / 60);
278
- const s = total % 60;
279
- return m > 0 ? `${m}m ${s}s` : `${s}s`;
280
- }
281
- function TaskRow(props) {
282
- const { task, onAck } = props;
283
- const [expanded, setExpanded] = React.useState(false);
284
- // Live stopwatch: re-render every second while running so the elapsed time
285
- // ticks up; on finish the effect clears and the time freezes at the total.
286
- const [nowMs, setNowMs] = React.useState(() => Date.now());
287
- React.useEffect(() => {
288
- if (task.status !== "running")
289
- return undefined;
290
- const timer = setInterval(() => setNowMs(Date.now()), 1000);
291
- return () => clearInterval(timer);
292
- }, [task.status]);
293
- const startedMs = task.startedAt ? Date.parse(task.startedAt) : null;
294
- const finishedMs = task.finishedAt ? Date.parse(task.finishedAt) : null;
295
- const finished = TERMINAL_TASK_STATUSES.includes(task.status);
296
- // A full circle means "click me to check off". A still-running task never
297
- // fills completely, even if its agent already reports 100.
298
- const pct = task.status === "done"
299
- ? 100
300
- : finished
301
- ? task.progress
302
- : Math.min(task.progress, 95);
303
- const notes = task.notes.trim() || task.resultSummary?.trim() || "";
304
- return (React.createElement("div", { className: `task-card${task.status === "waiting" ? " waiting" : ""}`, onClick: () => setExpanded(!expanded) },
305
- React.createElement("div", { className: "task-row" },
306
- 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
307
- ? (event) => {
308
- event.stopPropagation();
309
- onAck(task.id, false);
310
- }
311
- : undefined }),
312
- React.createElement("span", { className: "task-title" }, task.title),
313
- task.status === "running" && startedMs != null ? (React.createElement("span", { className: "task-time" }, fmtDuration(nowMs - startedMs))) : task.status === "done" &&
314
- startedMs != null &&
315
- finishedMs != null ? (React.createElement("span", { className: "task-time" }, fmtDuration(finishedMs - startedMs))) : task.status !== "done" ? (React.createElement("span", { className: `task-status ${task.status}` }, task.status)) : null),
316
- React.createElement("div", { className: "task-bar" },
317
- React.createElement("div", { style: { width: `${pct}%` } })),
318
- expanded &&
319
- task.status === "running" &&
320
- props.feed &&
321
- props.feed.length > 0 ? (React.createElement(TaskFeed, { lines: props.feed })) : null,
322
- expanded && task.status !== "running" && notes ? (React.createElement("div", { className: "task-notes", dangerouslySetInnerHTML: renderMarkdown(notes) })) : null));
323
- }
324
- function TaskBoard(props) {
325
- // Hide only tasks that are acknowledged AND finished. An active
326
- // (running/waiting) task always shows, even if somehow acked -- a running
327
- // task must never go invisible while it (and its dependents) are live.
328
- const visible = props.tasks.filter((t) => !(t.acknowledged && TERMINAL_TASK_STATUSES.includes(t.status)));
329
- if (visible.length === 0)
330
- return null;
331
- 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 })))));
332
- }
333
- function Message(props) {
334
- const { msg } = props;
335
- // A reply that was nothing but task directives strips to empty -- the board
336
- // already shows the spawn, so don't render an empty husk of a card.
337
- if (msg.role === "assistant" &&
338
- msg.status !== "streaming" &&
339
- !msg.text.trim()) {
340
- return null;
341
- }
342
- if (msg.role === "log") {
343
- return React.createElement("div", { className: "msg msg-log" }, msg.text);
344
- }
345
- if (msg.role === "user") {
346
- return (React.createElement("div", { className: "msg msg-user" },
347
- (msg.attachments ?? []).map((name) => (React.createElement("img", { key: name, className: "msg-image", src: `${ATTACHMENT_URL_PREFIX}${name}`, alt: "" }))),
348
- msg.text ? React.createElement("div", { className: "msg-text" }, msg.text) : null));
349
- }
350
- const classes = ["msg", "msg-assistant"];
351
- if (msg.status === "streaming")
352
- classes.push("streaming");
353
- if (msg.status === "error")
354
- classes.push("msg-error");
355
- return (React.createElement("div", { className: classes.join(" ") },
356
- React.createElement("div", { className: "msg-text", dangerouslySetInnerHTML: renderMarkdown(msg.text) }),
357
- msg.status === "streaming" && msg.activity ? (React.createElement("div", { className: "msg-activity" },
358
- msg.activity,
359
- "...")) : null,
360
- msg.interrupted ? (React.createElement("div", { className: "msg-interrupted" }, "interrupted by your next message")) : null));
361
- }
362
- function MessageList(props) {
363
- const hostRef = React.useRef(null);
364
- // Distance from the bottom of the scroll region -- the value to preserve.
365
- // The list is bottom-anchored: when the viewport resizes (task board grows
366
- // or a card expands above), keep the same content at the bottom instead of
367
- // letting the chat get pushed around.
368
- const fromBottomRef = React.useRef(0);
369
- const pinnedRef = React.useRef(true);
370
- React.useLayoutEffect(() => {
371
- const host = hostRef.current;
372
- if (host && pinnedRef.current)
373
- host.scrollTop = host.scrollHeight;
374
- }, [props.messages]);
375
- React.useEffect(() => {
376
- const host = hostRef.current;
377
- if (!host)
378
- return;
379
- const observer = new ResizeObserver(() => {
380
- host.scrollTop =
381
- host.scrollHeight - host.clientHeight - fromBottomRef.current;
382
- });
383
- observer.observe(host);
384
- return () => observer.disconnect();
385
- }, []);
386
- const handleScroll = () => {
387
- const host = hostRef.current;
388
- if (!host)
389
- return;
390
- fromBottomRef.current =
391
- host.scrollHeight - host.scrollTop - host.clientHeight;
392
- pinnedRef.current = fromBottomRef.current < 48;
393
- };
394
- return (React.createElement("div", { id: "chat-messages", ref: hostRef, onScroll: handleScroll },
395
- props.messages.length === 0 ? (React.createElement("div", { id: "chat-empty" }, "Tell the agent what you want to make.")) : null,
396
- props.messages.map((msg) => (React.createElement(Message, { key: msg.id, msg: msg })))));
397
- }
398
- function readImageFiles(files, add) {
399
- for (const file of Array.from(files)) {
400
- if (!file.type.startsWith("image/"))
401
- continue;
402
- const reader = new FileReader();
403
- reader.onload = () => {
404
- if (typeof reader.result === "string")
405
- add({ name: file.name, dataUrl: reader.result });
406
- };
407
- reader.readAsDataURL(file);
408
- }
409
- }
410
- function InputRow(props) {
411
- const [value, setValue] = React.useState("");
412
- const [pending, setPending] = React.useState([]);
413
- const inputRef = React.useRef(null);
414
- const autosize = () => {
415
- const el = inputRef.current;
416
- if (!el)
417
- return;
418
- el.style.height = "auto";
419
- el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
420
- };
421
- React.useLayoutEffect(autosize, [value]);
422
- const addImage = (img) => {
423
- setPending((prev) => (prev.length >= 6 ? prev : [...prev, img]));
424
- };
425
- const send = () => {
426
- const text = value.trim();
427
- if (!text && pending.length === 0)
428
- return;
429
- props.onSend(text, pending);
430
- setValue("");
431
- setPending([]);
432
- };
433
- return (React.createElement(React.Fragment, null,
434
- 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,
435
- React.createElement("div", { id: "chat-input-row" },
436
- React.createElement("textarea", { id: "chat-input", ref: inputRef, rows: 1, placeholder: "Message the agent...", value: value, onChange: (event) => setValue(event.target.value), onPaste: (event) => {
437
- const files = event.clipboardData?.files;
438
- if (files && files.length > 0) {
439
- event.preventDefault();
440
- readImageFiles(files, addImage);
441
- }
442
- }, onKeyDown: (event) => {
443
- if (event.key === "Enter" &&
444
- !event.shiftKey &&
445
- !event.metaKey &&
446
- !event.altKey) {
447
- event.preventDefault();
448
- send();
449
- }
450
- } }),
451
- React.createElement("button", { id: "chat-send", type: "button", tabIndex: -1, onClick: send }, "Send"))));
452
- }
453
- function App() {
454
- const [messages, setMessages] = React.useState([]);
455
- const [tasks, setTasks] = React.useState([]);
456
- const [feeds, setFeeds] = React.useState({});
457
- const sendRef = React.useRef(() => undefined);
458
- React.useEffect(() => {
459
- const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
460
- const wsUrl = `${wsProtocol}//${window.location.host}/__castle/agent`;
461
- let ws = null;
462
- let alive = true;
463
- let retryMs = 500;
464
- const outbox = [];
465
- sendRef.current = (payload) => {
466
- const data = JSON.stringify(payload);
467
- if (ws && ws.readyState === WebSocket.OPEN)
468
- ws.send(data);
469
- else
470
- outbox.push(data);
471
- };
472
- const connect = () => {
473
- if (!alive || document.visibilityState === "hidden")
474
- return;
475
- const sock = new WebSocket(wsUrl);
476
- ws = sock;
477
- sock.addEventListener("open", () => {
478
- retryMs = 500;
479
- while (outbox.length > 0 && sock.readyState === sock.OPEN) {
480
- const queued = outbox.shift();
481
- if (queued)
482
- sock.send(queued);
483
- }
484
- });
485
- sock.addEventListener("message", (event) => {
486
- try {
487
- applyServerEvent(JSON.parse(String(event.data)), setMessages, setTasks, setFeeds);
488
- }
489
- catch {
490
- /* malformed frame */
491
- }
492
- });
493
- sock.addEventListener("close", () => {
494
- if (ws !== sock)
495
- return;
496
- ws = null;
497
- if (!alive)
498
- return;
499
- window.setTimeout(connect, retryMs);
500
- retryMs = Math.min(retryMs * 2, 15000);
501
- });
502
- };
503
- const onVisibility = () => {
504
- if (document.visibilityState !== "hidden" && !ws)
505
- connect();
506
- };
507
- const onSetBackend = (event) => {
508
- const detail = event
509
- .detail;
510
- if (!detail?.key || !detail.value)
511
- return;
512
- sendRef.current({ type: "set-settings", [detail.key]: detail.value });
513
- };
514
- document.addEventListener("visibilitychange", onVisibility);
515
- document.addEventListener("castle-set-backend", onSetBackend);
516
- connect();
517
- return () => {
518
- alive = false;
519
- document.removeEventListener("visibilitychange", onVisibility);
520
- document.removeEventListener("castle-set-backend", onSetBackend);
521
- ws?.close();
522
- };
523
- }, []);
524
- return (React.createElement(React.Fragment, null,
525
- React.createElement(TaskBoard, { tasks: tasks, feeds: feeds, onAck: (id, rejected) => sendRef.current({ type: "task-ack", id, rejected }) }),
526
- React.createElement(MessageList, { messages: messages }),
527
- React.createElement(InputRow, { onSend: (text, images) => sendRef.current({ type: "user-message", text, images }) })));
528
- }
529
- initPanelChrome();
530
- const chatHost = document.getElementById("chat-host");
531
- if (chatHost) {
532
- ReactDOM.createRoot(chatHost).render(React.createElement(App, null));
533
- if (document.body.classList.contains("term-open") &&
534
- !document.body.classList.contains("term-view")) {
535
- window.setTimeout(focusChatInput, 0);
536
- }
537
- }
538
- export {};
@@ -1 +0,0 @@
1
- export {};