chamba 0.6.0 → 0.7.0
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/README.md +13 -6
- package/dist/commands/dev.js +11 -11
- package/dist/commands/settings.js +1 -1
- package/dist/lib/agent-context.js +33 -7
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/dockerfile-builder.js +2 -1
- package/dist/lib/ports.js +4 -4
- package/dist/lib/safe-rm.js +13 -3
- package/dist/lib/webterm.js +7 -7
- package/package.json +3 -5
- package/templates/Dockerfile +20 -1
- package/templates/context/web-pane-craft.md +1 -1
- package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
- package/templates/pane-apps/client/specs/index.html +13 -0
- package/templates/pane-apps/server/specs.mjs +1588 -0
- package/templates/skills/chamba-statusline/SKILL.md +1 -1
- package/templates/skills/dx-spec/SKILL.md +365 -0
- package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
- package/templates/skills/dx-spec/references/review-guide.md +173 -0
- package/templates/skills/dx-spec/references/spec-guide.md +125 -0
- package/templates/skills/dx-spec/references/stages.md +399 -0
- package/templates/skills/dx-spec-config/SKILL.md +313 -0
- package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
- package/templates/skills/dx-spec-execute/SKILL.md +324 -0
- package/templates/specs.sh +106 -0
- package/templates/webterm/README.md +50 -14
- package/templates/webterm/artifacts.js +11 -11
- package/templates/webterm/config.js +52 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +14 -3
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +5 -2
- package/templates/webterm/public/app/connection.js +2 -2
- package/templates/webterm/public/app/dictation.js +1 -1
- package/templates/webterm/public/app/dom.js +13 -5
- package/templates/webterm/public/app/frames.js +8 -1
- package/templates/webterm/public/app/main.js +8 -2
- package/templates/webterm/public/app/new-session.js +1 -1
- package/templates/webterm/public/app/pane-shell.js +315 -0
- package/templates/webterm/public/app/pane.js +58 -183
- package/templates/webterm/public/app/specs-host.js +222 -0
- package/templates/webterm/public/app/state.js +1 -1
- package/templates/webterm/public/app/tabs.js +1 -1
- package/templates/webterm/public/app/terminal.js +8 -0
- package/templates/webterm/public/index.html +51 -27
- package/templates/webterm/public/styles.css +144 -30
- package/templates/webterm/server.js +300 -11
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +2 -2
- package/templates/webterm/specs.js +358 -0
- package/templates/webterm/tool-document.js +67 -0
- package/templates/webterm/typed-line.js +85 -0
|
@@ -1,58 +1,47 @@
|
|
|
1
|
-
// pane.js - the
|
|
1
|
+
// pane.js - the Pages tool: the pages an agent published, beside the terminal it published them from.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// outside its frame.
|
|
3
|
+
// One of the tools the pane holds (pane-shell.js holds them), and the whole of what this module draws is its
|
|
4
|
+
// own panel - the chips bar, the counter, and the page. The pane's own furniture, the tab bar and the width,
|
|
5
|
+
// is the shell's, so nothing here can move the pane or say which tool the user is looking at.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// is
|
|
7
|
+
// The chips bar is chrome this interface draws around a page it does not trust (pane-frame.js renders that
|
|
8
|
+
// part). Keeping the two apart is the point: a published page can never fake which session and which page the
|
|
9
|
+
// user is looking at, because everything that says so is drawn outside its frame.
|
|
10
|
+
//
|
|
11
|
+
// What this tool shows belongs to the attached session, and it follows the tabs: switching sessions switches
|
|
12
|
+
// the list, the selection, and the page.
|
|
11
13
|
//
|
|
12
14
|
// A page that arrives opens itself, and says so with one pulse of the pane's edge. It never takes the
|
|
13
15
|
// keyboard: the caret stays in the composer mid-sentence, which is the price of the pane opening at all.
|
|
14
16
|
// "Arrives" is the server's own unread flag, not "new to this window" - the list here starts empty on every
|
|
15
17
|
// reload, and a page nobody has opened in a session nobody has visited is still an arrival when they get to
|
|
16
18
|
// it. A page for a session this window is not looking at touches nothing here; it marks that session's tab
|
|
17
|
-
// (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits.
|
|
19
|
+
// (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits. A page that lands
|
|
20
|
+
// while the pane is open on another tool marks the Pages tab and waits there: an arrival is worth saying, and
|
|
21
|
+
// never worth taking the screen for.
|
|
18
22
|
//
|
|
19
|
-
// The
|
|
23
|
+
// The tool exists before any of that. A session that has published nothing still has its tab, and opening it
|
|
20
24
|
// says what the pane is for and what to ask for - a feature nobody can see is a feature nobody uses, and this
|
|
21
25
|
// one is asked for in words rather than found in a menu.
|
|
22
26
|
|
|
23
27
|
import { sendFrame } from "./connection.js";
|
|
24
|
-
import {
|
|
25
|
-
chips,
|
|
26
|
-
grip,
|
|
27
|
-
pageBigger,
|
|
28
|
-
pagebar,
|
|
29
|
-
pageCount,
|
|
30
|
-
pageDoc,
|
|
31
|
-
pageSizeVal,
|
|
32
|
-
pageSmaller,
|
|
33
|
-
pane,
|
|
34
|
-
paneCollapse,
|
|
35
|
-
paneNext,
|
|
36
|
-
panePrev,
|
|
37
|
-
spine,
|
|
38
|
-
spineBadge,
|
|
39
|
-
} from "./dom.js";
|
|
28
|
+
import { chips, pageBigger, pagebar, pageCount, pageDoc, pageSizeVal, pageSmaller, paneNext, panePrev, toolPages } from "./dom.js";
|
|
40
29
|
import { arrivingPage } from "./pane-arrival.js";
|
|
41
30
|
import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js";
|
|
31
|
+
import {
|
|
32
|
+
flashPane,
|
|
33
|
+
isPaneCollapsed,
|
|
34
|
+
isPaneShut,
|
|
35
|
+
isToolOpen,
|
|
36
|
+
noteTool,
|
|
37
|
+
PAGES,
|
|
38
|
+
registerTool,
|
|
39
|
+
rememberPageSize,
|
|
40
|
+
setPaneCollapsed,
|
|
41
|
+
storedPageSize,
|
|
42
|
+
} from "./pane-shell.js";
|
|
42
43
|
import { attachedSid } from "./state.js";
|
|
43
44
|
|
|
44
|
-
// Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
|
|
45
|
-
// threshold, and the width the spine itself takes.
|
|
46
|
-
const COLLAPSE_AT = 150;
|
|
47
|
-
// What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
|
|
48
|
-
const DEFAULT_FRACTION = 0.44;
|
|
49
|
-
const MAX_FRACTION = 0.7;
|
|
50
|
-
// The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
|
|
51
|
-
// it was put away at, a pane you have just asked for has to be wide enough to read a page in.
|
|
52
|
-
const MIN_OPEN_FRACTION = 1 / 3;
|
|
53
|
-
// The window's own pane preferences: how wide, whether it is collapsed, and how big a page is drawn in it.
|
|
54
|
-
// Not the session's - a pane is furniture, and furniture does not move because you looked at another tab.
|
|
55
|
-
const GEOMETRY_KEY = "webterm-pane";
|
|
56
45
|
// The sizes a page can be shown at, smallest first, and where an untouched window starts. The spacing is
|
|
57
46
|
// deliberately uneven: a correction near normal reading size wants a fine step, and someone going to 150%
|
|
58
47
|
// wants to get there in two presses.
|
|
@@ -66,39 +55,13 @@ const AGE_TICK_MS = 30_000;
|
|
|
66
55
|
// heard about, so switching back to a tab lands on the page it was left on.
|
|
67
56
|
const panes = new Map();
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
let
|
|
74
|
-
// Which of TEXT_SIZES the shown page is drawn at.
|
|
75
|
-
let sizeStep = DEFAULT_SIZE_STEP;
|
|
76
|
-
// What the last render decided, for the two things that only need to know whether the pane is showing.
|
|
77
|
-
let shut = true;
|
|
78
|
-
|
|
79
|
-
// --- What the window remembers -----------------------------------------------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
|
|
83
|
-
if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
|
|
84
|
-
if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
|
|
85
|
-
// The multiplier is stored rather than its position, so a window that was left at a size this version no
|
|
86
|
-
// longer offers falls back to the default instead of landing between two steps.
|
|
87
|
-
const step = TEXT_SIZES.indexOf(stored.size);
|
|
88
|
-
if (step !== -1) sizeStep = step;
|
|
89
|
-
} catch {
|
|
90
|
-
// A disabled or full store only means the pane opens at its default width.
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function rememberGeometry() {
|
|
94
|
-
try {
|
|
95
|
-
localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, size: TEXT_SIZES[sizeStep] }));
|
|
96
|
-
} catch {
|
|
97
|
-
// The pane still works for this page's lifetime.
|
|
98
|
-
}
|
|
99
|
-
}
|
|
58
|
+
// Which of TEXT_SIZES the shown page is drawn at. The multiplier is what the pane stores, so a window that
|
|
59
|
+
// was left at a size this version no longer offers falls back to the default instead of landing between two
|
|
60
|
+
// steps.
|
|
61
|
+
const storedStep = TEXT_SIZES.indexOf(storedPageSize());
|
|
62
|
+
let sizeStep = storedStep === -1 ? DEFAULT_SIZE_STEP : storedStep;
|
|
100
63
|
|
|
101
|
-
// --- The
|
|
64
|
+
// --- The tool's own state ----------------------------------------------------------------------------------------------------------------
|
|
102
65
|
|
|
103
66
|
function paneFor(sid) {
|
|
104
67
|
let entry = panes.get(sid);
|
|
@@ -122,14 +85,16 @@ export function applyPages(msg) {
|
|
|
122
85
|
// The page this frame opens, if it opens one - the rule itself is in pane-arrival.js, where it can be run,
|
|
123
86
|
// and so is what a burst of publishes does under it.
|
|
124
87
|
const arrived = arrivingPage(entry.pages);
|
|
88
|
+
// It is selected either way, so the tab the user comes back to is on the page that arrived. Only the
|
|
89
|
+
// saying-so is held back while another tool has the pane.
|
|
125
90
|
if (arrived !== null) {
|
|
126
91
|
select(msg.sid, arrived.id, { user: false });
|
|
127
|
-
|
|
92
|
+
if (isToolOpen(PAGES)) flashPane();
|
|
128
93
|
}
|
|
129
94
|
// A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
|
|
130
95
|
// has said, already opens the pane when its session stops being empty, and settling it here would open
|
|
131
96
|
// the pane in every other empty session in this window too.
|
|
132
|
-
if (arrived !== null &&
|
|
97
|
+
if (arrived !== null && isToolOpen(PAGES) && isPaneCollapsed()) setPaneCollapsed(false);
|
|
133
98
|
else render();
|
|
134
99
|
}
|
|
135
100
|
|
|
@@ -141,7 +106,7 @@ export function prunePanes(live) {
|
|
|
141
106
|
}
|
|
142
107
|
|
|
143
108
|
/**
|
|
144
|
-
* Draw the
|
|
109
|
+
* Draw the tool for whatever session this window is now on, asking for its pages when this window has never
|
|
145
110
|
* been told them. The server pushes the list when a window lands on a session, so this covers the frame that
|
|
146
111
|
* did not arrive - a socket replaced mid-attach, or a window that took a session over from another one.
|
|
147
112
|
*/
|
|
@@ -257,25 +222,16 @@ function chipFor(sid, page, selected) {
|
|
|
257
222
|
return chip;
|
|
258
223
|
}
|
|
259
224
|
|
|
260
|
-
// How wide, and collapsed or not. Its own function because a drag is the one thing that changes the pane
|
|
261
|
-
// without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
|
|
262
|
-
function applyGeometry(collapsedNow = shut) {
|
|
263
|
-
pane.classList.toggle("collapsed", collapsedNow);
|
|
264
|
-
// The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
|
|
265
|
-
// last word: the collapsed spine and the narrow-screen layout are rules, and an inline width would beat
|
|
266
|
-
// both of them.
|
|
267
|
-
pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
225
|
// --- How big a page is drawn -------------------------------------------------------------------------------------------------------------
|
|
271
226
|
|
|
272
|
-
// The size is a property on
|
|
273
|
-
// the frame's document has an opaque origin and the shell cannot reach into it, and renderPage()
|
|
274
|
-
// iframe away and builds a fresh one on every chip click, so anything set on the frame itself
|
|
275
|
-
// put back on every one of those paths. Setting it out here costs nothing and cannot be
|
|
227
|
+
// The size is a property on this tool's panel, and the stylesheet zooms the frame from it. Two reasons, both
|
|
228
|
+
// structural: the frame's document has an opaque origin and the shell cannot reach into it, and renderPage()
|
|
229
|
+
// throws the iframe away and builds a fresh one on every chip click, so anything set on the frame itself
|
|
230
|
+
// would have to be put back on every one of those paths. Setting it out here costs nothing and cannot be
|
|
231
|
+
// missed.
|
|
276
232
|
function applyTextSize() {
|
|
277
233
|
const size = TEXT_SIZES[sizeStep];
|
|
278
|
-
|
|
234
|
+
toolPages.style.setProperty("--page-zoom", String(size));
|
|
279
235
|
pageSizeVal.textContent = `${Math.round(size * 100)}%`;
|
|
280
236
|
// A control that can do nothing says so, rather than swallowing the press.
|
|
281
237
|
pageSmaller.disabled = sizeStep === 0;
|
|
@@ -287,49 +243,25 @@ function stepTextSize(by) {
|
|
|
287
243
|
if (next === sizeStep) return;
|
|
288
244
|
sizeStep = next;
|
|
289
245
|
applyTextSize();
|
|
290
|
-
|
|
246
|
+
rememberPageSize(TEXT_SIZES[sizeStep]);
|
|
291
247
|
}
|
|
292
248
|
|
|
293
249
|
pageSmaller.addEventListener("click", () => stepTextSize(-1));
|
|
294
250
|
pageBigger.addEventListener("click", () => stepTextSize(1));
|
|
295
251
|
applyTextSize();
|
|
296
252
|
|
|
297
|
-
// --- Saying that a page arrived ----------------------------------------------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
// One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
|
|
300
|
-
const ARRIVAL_MS = 1_000;
|
|
301
|
-
let arrivalTimer = null;
|
|
302
|
-
|
|
303
|
-
// The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
|
|
304
|
-
// is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
|
|
305
|
-
// the two last the same moment.
|
|
306
|
-
function flashArrival() {
|
|
307
|
-
clearTimeout(arrivalTimer);
|
|
308
|
-
// A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
|
|
309
|
-
// notice, on again. Reading a layout property is what makes the restart real.
|
|
310
|
-
pane.classList.remove("arriving");
|
|
311
|
-
void pane.offsetWidth;
|
|
312
|
-
pane.classList.add("arriving");
|
|
313
|
-
arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
253
|
function render() {
|
|
317
254
|
const sid = attachedSid;
|
|
318
255
|
const entry = sid === null ? null : (panes.get(sid) ?? null);
|
|
319
256
|
const pages = entry?.pages ?? [];
|
|
320
257
|
const unread = pages.filter((page) => page.unread).length;
|
|
321
258
|
|
|
322
|
-
// Nothing published in this session, and nothing to say about it. The
|
|
323
|
-
// someone opens it - because a pane that only appears once an agent has used it is one
|
|
324
|
-
// ask for.
|
|
259
|
+
// Nothing published in this session, and nothing to say about it. The tab stays - and the pane stays as
|
|
260
|
+
// the spine, until someone opens it - because a pane that only appears once an agent has used it is one
|
|
261
|
+
// nobody knows to ask for. Saying so is the shell's business: the tab's mark, and whether an untouched
|
|
262
|
+
// pane keeps out of the way, are the pane's decisions to take from what its open tool has.
|
|
325
263
|
const bare = pages.length === 0 && !entry?.notice;
|
|
326
|
-
|
|
327
|
-
pane.hidden = false;
|
|
328
|
-
grip.hidden = bare && shut;
|
|
329
|
-
|
|
330
|
-
applyGeometry(shut);
|
|
331
|
-
spineBadge.textContent = unread > 0 ? String(unread) : "";
|
|
332
|
-
spineBadge.hidden = unread === 0;
|
|
264
|
+
noteTool(PAGES, { unread, empty: bare });
|
|
333
265
|
|
|
334
266
|
chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
|
|
335
267
|
const at = pages.findIndex((page) => page.id === entry?.selected);
|
|
@@ -364,75 +296,18 @@ function render() {
|
|
|
364
296
|
chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
365
297
|
}
|
|
366
298
|
|
|
299
|
+
// The tool draws itself whenever the pane asks - a tab click, a width that changed, a session switch.
|
|
300
|
+
registerTool(PAGES, render);
|
|
301
|
+
|
|
367
302
|
setInterval(() => {
|
|
368
|
-
if (!
|
|
303
|
+
if (!isPaneShut() && isToolOpen(PAGES)) render();
|
|
369
304
|
}, AGE_TICK_MS);
|
|
370
305
|
|
|
371
|
-
// --- The divider, and the spine ----------------------------------------------------------------------------------------------------------
|
|
372
|
-
|
|
373
|
-
// Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
|
|
374
|
-
// spine rather than a sliver. Pointer capture keeps the drag alive over the terminal and the frame alike -
|
|
375
|
-
// a page is a document with its own event loop, and without capture the pointer would be lost in it.
|
|
376
|
-
let dragging = false;
|
|
377
|
-
// The width the pane had when the drag started. Dragging it shut is a request to put it away, not a request
|
|
378
|
-
// for a 150px pane, so crossing the threshold hands its old width back rather than keeping the sliver the
|
|
379
|
-
// pointer passed through on the way there.
|
|
380
|
-
let widthBeforeDrag = null;
|
|
381
|
-
|
|
382
|
-
grip.addEventListener("pointerdown", (event) => {
|
|
383
|
-
dragging = true;
|
|
384
|
-
widthBeforeDrag = width;
|
|
385
|
-
document.body.classList.add("dragging");
|
|
386
|
-
grip.setPointerCapture(event.pointerId);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
grip.addEventListener("pointermove", (event) => {
|
|
390
|
-
if (!dragging) return;
|
|
391
|
-
const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
|
|
392
|
-
if (wanted < COLLAPSE_AT) {
|
|
393
|
-
collapsed = true;
|
|
394
|
-
width = widthBeforeDrag;
|
|
395
|
-
} else {
|
|
396
|
-
collapsed = false;
|
|
397
|
-
width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
|
|
398
|
-
}
|
|
399
|
-
// A drag is an answer too, so what it decides is what the next render starts from.
|
|
400
|
-
shut = collapsed;
|
|
401
|
-
applyGeometry(shut);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
function endDrag() {
|
|
405
|
-
if (!dragging) return;
|
|
406
|
-
dragging = false;
|
|
407
|
-
document.body.classList.remove("dragging");
|
|
408
|
-
rememberGeometry();
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
grip.addEventListener("pointerup", endDrag);
|
|
412
|
-
grip.addEventListener("pointercancel", endDrag);
|
|
413
|
-
|
|
414
|
-
// Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
|
|
415
|
-
// resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
|
|
416
|
-
function setCollapsed(next) {
|
|
417
|
-
collapsed = next;
|
|
418
|
-
shut = next;
|
|
419
|
-
// Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one from
|
|
420
|
-
// a window remembered narrower than this one, has to open into something a page fits in.
|
|
421
|
-
if (!collapsed) {
|
|
422
|
-
const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
|
|
423
|
-
if (width !== null && width < floor) width = floor;
|
|
424
|
-
}
|
|
425
|
-
rememberGeometry();
|
|
426
|
-
render();
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
paneCollapse.addEventListener("click", () => setCollapsed(true));
|
|
430
|
-
spine.addEventListener("click", () => setCollapsed(false));
|
|
431
|
-
|
|
432
|
-
// The chips bar scrolls by roughly a bar's worth, which is what the arrows in the mock do.
|
|
433
|
-
panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
434
|
-
paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
435
306
|
// A bar that fits at one width may not at another, and the arrows only exist when it does not.
|
|
436
307
|
window.addEventListener("resize", render);
|
|
308
|
+
// The bar hides its scrollbar, so these are the only way to reach a chip that scrolled out of it. A little
|
|
309
|
+
// under a full bar each time, so the chip at the edge stays on the screen and says where the bar now is.
|
|
310
|
+
panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
311
|
+
paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
437
312
|
// The pagebar is chrome: a click on it must not leave the terminal without focus for no reason.
|
|
438
313
|
pagebar.addEventListener("mousedown", (event) => event.preventDefault());
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// specs-host.js - the Specs tool: the spec workspace, in a frame of its own.
|
|
2
|
+
//
|
|
3
|
+
// The tool's client is not part of this interface. It is built elsewhere and rendered here in a frame with an
|
|
4
|
+
// opaque origin, the same way a published page is, so the shell's key never enters it and one rendering bug
|
|
5
|
+
// can leak spec reading at worst. This module is the host around that frame: it owns the panel, the frame,
|
|
6
|
+
// and the one message that gives the frame what it needs to ask the server anything.
|
|
7
|
+
//
|
|
8
|
+
// The handover is the whole point of this file. The frame gets a Specs-scoped key that opens the tool's data
|
|
9
|
+
// routes and a path prefix that opens workspace files, and it gets them once, unprompted, on the load of the
|
|
10
|
+
// document this shell put there. It is never handed out on request: a window object survives a navigation, so
|
|
11
|
+
// a document that navigated itself into this frame would ask with the same identity as the one that was
|
|
12
|
+
// loaded. A second load is therefore read as a navigation, and the frame is thrown away and built again.
|
|
13
|
+
|
|
14
|
+
import { sendFrame } from "./connection.js";
|
|
15
|
+
import { toolSpecs } from "./dom.js";
|
|
16
|
+
import { noteTool, registerTool, SPECS } from "./pane-shell.js";
|
|
17
|
+
import { KEY_HEADER, WEB_KEY } from "./state.js";
|
|
18
|
+
|
|
19
|
+
// Where the built client is served from, and where the shell asks for the frame's credentials. The second one
|
|
20
|
+
// is behind the master key in a header, which is the shell's own gate and never the frame's.
|
|
21
|
+
const FRAME_SRC = "/pane-apps/specs/index.html";
|
|
22
|
+
const KEYS_URL = "/specs/keys";
|
|
23
|
+
|
|
24
|
+
// What the shell and the frame call each other's messages. Namespaced because a frame may be talking to
|
|
25
|
+
// something else of its own, and every message is checked against the frame it must come from.
|
|
26
|
+
const HANDOVER = "specs:credentials";
|
|
27
|
+
const CHANGE = "specs:change";
|
|
28
|
+
const OPEN = "specs:open";
|
|
29
|
+
const DELIVER = "specs:deliver";
|
|
30
|
+
const WAITING = "specs:waiting";
|
|
31
|
+
const DELIVERED = "specs:delivered";
|
|
32
|
+
|
|
33
|
+
// How many times a frame that navigates itself is rebuilt before the tab says so instead. A client that keeps
|
|
34
|
+
// leaving the document it was given is broken or is being driven, and either way rebuilding it for ever is a
|
|
35
|
+
// spinning window rather than a recovery.
|
|
36
|
+
const MAX_REBUILDS = 3;
|
|
37
|
+
|
|
38
|
+
let frame = null;
|
|
39
|
+
let handed = false;
|
|
40
|
+
let rebuilds = 0;
|
|
41
|
+
let credentials = null;
|
|
42
|
+
let asking = false;
|
|
43
|
+
let failure = null;
|
|
44
|
+
// How many things are waiting for the user inside the tool. Kept here rather than in the frame, because the
|
|
45
|
+
// mark has to hold while another tool is open and the frame is not being drawn.
|
|
46
|
+
let waiting = 0;
|
|
47
|
+
|
|
48
|
+
/** Fetch the frame's credentials. The shell holds the master key; this is the only route it opens for Specs. */
|
|
49
|
+
async function fetchCredentials() {
|
|
50
|
+
if (asking) return;
|
|
51
|
+
asking = true;
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(KEYS_URL, { headers: { [KEY_HEADER]: WEB_KEY }, cache: "no-store" });
|
|
54
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
55
|
+
const answer = await response.json();
|
|
56
|
+
if (typeof answer?.key !== "string" || typeof answer?.raw !== "string") throw new Error("no credentials");
|
|
57
|
+
credentials = answer;
|
|
58
|
+
failure = null;
|
|
59
|
+
} catch {
|
|
60
|
+
failure = "The Specs tool could not be reached. The next time this tab is opened it tries again.";
|
|
61
|
+
} finally {
|
|
62
|
+
asking = false;
|
|
63
|
+
}
|
|
64
|
+
// Drawn whether the tab is open or not. The panel is off the screen when it is not, so nothing is shown
|
|
65
|
+
// either way, but this is the step that puts the frame up - and the tool has to be running to say what
|
|
66
|
+
// is waiting for the user, wherever in the pane that user happens to be.
|
|
67
|
+
render();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Put a fresh frame in the panel, replacing whatever was there. */
|
|
71
|
+
function build() {
|
|
72
|
+
const next = document.createElement("iframe");
|
|
73
|
+
next.className = "tool-frame";
|
|
74
|
+
// The whole boundary in one attribute: scripts and forms, and no same-origin, so the document has an
|
|
75
|
+
// opaque origin and nothing of this window is reachable from inside it - not the DOM, not storage, and
|
|
76
|
+
// not the URL that carries the master key. Set before the src, since the sandbox of a frame that already
|
|
77
|
+
// has a document is not what a later attribute says it is.
|
|
78
|
+
next.setAttribute("sandbox", "allow-scripts allow-forms");
|
|
79
|
+
// A frame inherits its embedder's referrer, and this window's URL carries the master key - both as
|
|
80
|
+
// `document.referrer` inside the frame and as the `Referer` header on everything it loads.
|
|
81
|
+
next.setAttribute("referrerpolicy", "no-referrer");
|
|
82
|
+
next.setAttribute("title", "Specs");
|
|
83
|
+
next.addEventListener("load", () => onLoad(next));
|
|
84
|
+
next.src = FRAME_SRC;
|
|
85
|
+
frame = next;
|
|
86
|
+
handed = false;
|
|
87
|
+
toolSpecs.replaceChildren(next);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The frame finished loading a document. The first one is the client; a second one is a navigation. */
|
|
91
|
+
function onLoad(loaded) {
|
|
92
|
+
if (loaded !== frame) return;
|
|
93
|
+
if (!handed) {
|
|
94
|
+
handed = true;
|
|
95
|
+
loaded.contentWindow?.postMessage({ t: HANDOVER, key: credentials.key, raw: credentials.raw }, "*");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (rebuilds >= MAX_REBUILDS) {
|
|
99
|
+
frame = null;
|
|
100
|
+
failure = "The Specs tool kept leaving the page it was given, so it was stopped.";
|
|
101
|
+
render();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
rebuilds += 1;
|
|
105
|
+
build();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One sentence in the middle of the panel: what the tab is doing, or why it is doing nothing. */
|
|
109
|
+
function say(line) {
|
|
110
|
+
const wrap = document.createElement("div");
|
|
111
|
+
wrap.className = "pane-intro";
|
|
112
|
+
const title = document.createElement("h2");
|
|
113
|
+
title.textContent = "Specs";
|
|
114
|
+
const lede = document.createElement("p");
|
|
115
|
+
lede.textContent = line;
|
|
116
|
+
wrap.append(title, lede);
|
|
117
|
+
toolSpecs.replaceChildren(wrap);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function render() {
|
|
121
|
+
// The tab always has something to show, so it never asks the pane to keep out of the way. What is on it
|
|
122
|
+
// is what the tool last said is waiting for the user, which holds while another tool is open.
|
|
123
|
+
noteTool(SPECS, { unread: waiting, empty: false });
|
|
124
|
+
if (failure !== null) {
|
|
125
|
+
// Said once and then forgotten, so coming back to the tab is what tries again.
|
|
126
|
+
const line = failure;
|
|
127
|
+
failure = null;
|
|
128
|
+
frame = null;
|
|
129
|
+
say(line);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// A frame that is up is left alone: it holds the tool's whole state, and rebuilding it would be a reload
|
|
133
|
+
// every time the user came back to the tab.
|
|
134
|
+
if (frame !== null) return;
|
|
135
|
+
if (credentials === null) {
|
|
136
|
+
say("Opening the specs in this repository.");
|
|
137
|
+
fetchCredentials();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
build();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The disk moved under a spec. Passed straight in: the frame decides what is worth re-reading. */
|
|
144
|
+
export function applySpecs(msg) {
|
|
145
|
+
if (frame === null || !handed) return;
|
|
146
|
+
frame.contentWindow?.postMessage({ t: CHANGE, change: msg.change }, "*");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether a message is the tool asking for something, rather than anything else on this window.
|
|
151
|
+
*
|
|
152
|
+
* Two checks, and each one holds without the other. The sender must be the frame's own window, which refuses
|
|
153
|
+
* a workspace document nested inside the tool - it is a frame of its own and can post here. And the message
|
|
154
|
+
* must carry the Specs key, which only the document this shell loaded ever received, so a document that
|
|
155
|
+
* navigated the frame passes the window check and fails this one.
|
|
156
|
+
*/
|
|
157
|
+
function fromTool(event, message) {
|
|
158
|
+
if (frame === null || !handed || credentials === null) return false;
|
|
159
|
+
if (event.source !== frame.contentWindow) return false;
|
|
160
|
+
return typeof message.key === "string" && message.key === credentials.key;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A link the tool will not follow itself. The frame has to stay the document this shell put in it, since a
|
|
164
|
+
// navigation would keep the window identity every check here rests on, so an outside link is opened as a
|
|
165
|
+
// window of this interface instead - the web alone, and with nothing of this window carried into it.
|
|
166
|
+
window.addEventListener("message", (event) => {
|
|
167
|
+
const message = event.data;
|
|
168
|
+
if (!message || typeof message !== "object" || message.t !== OPEN) return;
|
|
169
|
+
if (!fromTool(event, message)) return;
|
|
170
|
+
let url;
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(String(message.url));
|
|
173
|
+
} catch {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return;
|
|
177
|
+
window.open(url.href, "_blank", "noopener,noreferrer");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// The tool saying what it is waiting on. The count is the tool's own, and the mark it puts on the tab is the
|
|
181
|
+
// shell's - which is what makes something awaiting an answer visible from any other tool in the pane.
|
|
182
|
+
window.addEventListener("message", (event) => {
|
|
183
|
+
const message = event.data;
|
|
184
|
+
if (!message || typeof message !== "object" || message.t !== WAITING) return;
|
|
185
|
+
if (!fromTool(event, message)) return;
|
|
186
|
+
const count = Number(message.count);
|
|
187
|
+
waiting = Number.isFinite(count) && count > 0 ? Math.min(Math.floor(count), 99) : 0;
|
|
188
|
+
noteTool(SPECS, { unread: waiting, empty: false });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// A delivery: the tool telling the agent beside it that something arrived. It rides this window's own socket,
|
|
192
|
+
// so the server resolves the session from the socket the way every other pane frame does, and nothing here
|
|
193
|
+
// or in the frame names one. What travels is the kind of event, a line of detail, the file the tool wrote,
|
|
194
|
+
// and whether the user agreed to start a session for it.
|
|
195
|
+
window.addEventListener("message", (event) => {
|
|
196
|
+
const message = event.data;
|
|
197
|
+
if (!message || typeof message !== "object" || message.t !== DELIVER) return;
|
|
198
|
+
if (!fromTool(event, message)) return;
|
|
199
|
+
sendFrame({
|
|
200
|
+
t: DELIVER,
|
|
201
|
+
event: String(message.event ?? ""),
|
|
202
|
+
detail: String(message.detail ?? ""),
|
|
203
|
+
path: String(message.path ?? ""),
|
|
204
|
+
create: message.create === true,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/** What the server made of the last delivery, passed back to the frame that asked for it. */
|
|
209
|
+
export function applySpecsDelivery(msg) {
|
|
210
|
+
if (frame === null || !handed) return;
|
|
211
|
+
frame.contentWindow?.postMessage({ t: DELIVERED, ok: msg.ok === true, error: msg.error ?? "" }, "*");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
registerTool(SPECS, render);
|
|
215
|
+
|
|
216
|
+
// The frame is built at startup rather than the first time the tab is opened, because the mark on the tab is
|
|
217
|
+
// the tool's to put there and the tool cannot say anything until it is running. Waiting for the first open
|
|
218
|
+
// would make "something is waiting for you" visible only to a user who already went and looked, which is the
|
|
219
|
+
// one reader who did not need telling - and a window reopened on another tool would show nothing at all.
|
|
220
|
+
// Nothing is drawn by this: the panel is off the screen until its tab is picked, and the frame holds the
|
|
221
|
+
// tool's state from here on, so opening the tab shows what is already there instead of loading it then.
|
|
222
|
+
render();
|
|
@@ -40,7 +40,7 @@ export function labelOf(sid, fallback) {
|
|
|
40
40
|
return sessions.find((entry) => entry.id === sid)?.label ?? fallback;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// The key that came with the URL. The server
|
|
43
|
+
// The key that came with the URL. The server creates a new one every time it starts and refuses everything
|
|
44
44
|
// without it, so this is also what goes stale: a window left open across a restart still holds the old key,
|
|
45
45
|
// which is the case the curtain explains rather than reconnecting forever.
|
|
46
46
|
export const WEB_KEY = new URLSearchParams(location.search).get("k") ?? "";
|
|
@@ -171,7 +171,7 @@ function renameEditor(entry) {
|
|
|
171
171
|
editor.className = "rename";
|
|
172
172
|
editor.value = editingValue;
|
|
173
173
|
editor.spellcheck = false;
|
|
174
|
-
// A soft
|
|
174
|
+
// A soft limit for the field; the server trims to its own limit and strips control characters.
|
|
175
175
|
editor.maxLength = 60;
|
|
176
176
|
editor.addEventListener("click", (event) => event.stopPropagation());
|
|
177
177
|
editor.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
@@ -87,10 +87,18 @@ term.onData((data) => {
|
|
|
87
87
|
// Keep the PTY's window size in step with the rendered terminal. Debounced: a resize storm
|
|
88
88
|
// (layout settling, pane drag) collapses to one fit + one resize, which cuts down the redraw
|
|
89
89
|
// artifacts the TUI shows when width changes mid-render.
|
|
90
|
+
//
|
|
91
|
+
// A terminal with no width on the screen is not a narrow terminal, and the PTY is not told about one.
|
|
92
|
+
// The pane at its full width leaves the row nothing, and the fit would answer with its own floor of two
|
|
93
|
+
// columns: the agent's TUI would take that as a real window, reflow its frame and its scrollback into
|
|
94
|
+
// two columns, and keep the mangled scrollback after the pane came back. Holding the last real size
|
|
95
|
+
// instead is what makes "squeezed out rather than hidden" true for the process as well as the layout -
|
|
96
|
+
// nothing is sent while it is out of the row, and the next pass sends the width it returns at.
|
|
90
97
|
let resizeTimer = null;
|
|
91
98
|
const resizeObserver = new ResizeObserver(() => {
|
|
92
99
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
93
100
|
resizeTimer = setTimeout(() => {
|
|
101
|
+
if (termEl.clientWidth === 0 || termEl.clientHeight === 0) return;
|
|
94
102
|
try {
|
|
95
103
|
fit.fit();
|
|
96
104
|
sendResize();
|