chamba 0.7.0 → 0.8.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/LICENSE +53 -14
- package/README.md +28 -8
- package/dist/commands/advanced.js +7 -278
- package/dist/commands/dev.js +11 -612
- package/dist/commands/doctor.js +1 -29
- package/dist/commands/menu.js +1 -80
- package/dist/commands/onboard.js +6 -230
- package/dist/commands/settings.js +15 -349
- package/dist/lib/agent-commands.js +2 -0
- package/dist/lib/agent-context.js +6 -210
- package/dist/lib/browser.js +1 -40
- package/dist/lib/chamba-yaml.js +10 -191
- package/dist/lib/constants.js +1 -125
- package/dist/lib/dockerfile-builder.js +41 -261
- package/dist/lib/env.js +2 -78
- package/dist/lib/git-env.js +1 -21
- package/dist/lib/global-config.js +4 -66
- package/dist/lib/pnpm-store.js +1 -19
- package/dist/lib/ports.js +1 -210
- package/dist/lib/safe-rm.js +1 -36
- package/dist/lib/sessions.js +2 -34
- package/dist/lib/shadows.js +1 -176
- package/dist/lib/skills.js +2 -0
- package/dist/lib/templates.js +1 -0
- package/dist/lib/webterm.js +1 -305
- package/dist/lib/workspace-identity.js +4 -260
- package/package.json +4 -4
- package/templates/Dockerfile +12 -19
- package/templates/claude-statusline.sh +5 -7
- package/templates/context/baseline.md +2 -0
- package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
- package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
- package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
- package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
- package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
- package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
- package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
- package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
- package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
- package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
- package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
- package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
- package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
- package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
- package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
- package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
- package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
- package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
- package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
- package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
- package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
- package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
- package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
- package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
- package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
- package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
- package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
- package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
- package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
- package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
- package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
- package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
- package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
- package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
- package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
- package/templates/pane-apps/client/reviews/index.html +15 -0
- package/templates/pane-apps/client/specs/index.html +3 -2
- package/templates/pane-apps/server/reviews.mjs +20 -0
- package/templates/pane-apps/server/specs.mjs +5 -1588
- package/templates/skills/chamba-statusline/SKILL.md +7 -19
- package/templates/skills/dx-review/SKILL.md +123 -0
- package/templates/skills/dx-review/references/acts.md +162 -0
- package/templates/skills/dx-spec/SKILL.md +223 -27
- package/templates/skills/dx-spec/references/principles-template.md +2 -0
- package/templates/skills/dx-spec/references/review-guide.md +4 -50
- package/templates/skills/dx-spec/references/spec-guide.md +24 -4
- package/templates/skills/dx-spec/references/stages.md +143 -58
- package/templates/skills/dx-spec-execute/SKILL.md +148 -35
- package/templates/startup.mjs +10 -4
- package/templates/tool-helper.sh +166 -0
- package/templates/webterm/README.md +60 -26
- package/templates/webterm/color.js +61 -0
- package/templates/webterm/config.js +35 -4
- package/templates/webterm/context/claude.md +0 -1
- package/templates/webterm/public/app/alerts.js +4 -4
- package/templates/webterm/public/app/anchored.js +81 -0
- package/templates/webterm/public/app/connection.js +9 -0
- package/templates/webterm/public/app/dom.js +2 -0
- package/templates/webterm/public/app/frames.js +51 -17
- package/templates/webterm/public/app/main.js +22 -8
- package/templates/webterm/public/app/new-session.js +13 -41
- package/templates/webterm/public/app/palette.js +58 -0
- package/templates/webterm/public/app/pane-shape.js +167 -0
- package/templates/webterm/public/app/pane-shell.js +199 -134
- package/templates/webterm/public/app/pane.js +33 -24
- package/templates/webterm/public/app/reviews-host.js +15 -0
- package/templates/webterm/public/app/specs-host.js +12 -218
- package/templates/webterm/public/app/state.js +5 -0
- package/templates/webterm/public/app/tabs.js +6 -2
- package/templates/webterm/public/app/theme.js +75 -27
- package/templates/webterm/public/app/tool-host.js +302 -0
- package/templates/webterm/public/app/workspace-color.js +192 -0
- package/templates/webterm/public/index.html +17 -3
- package/templates/webterm/public/styles.css +143 -21
- package/templates/webterm/server.js +198 -150
- package/templates/webterm/tools/commands.js +61 -0
- package/templates/webterm/tools/index.js +25 -0
- package/templates/webterm/tools/mount.js +337 -0
- package/templates/webterm/tools/paths.js +34 -0
- package/templates/webterm/tools/reviews.js +133 -0
- package/templates/webterm/tools/specs.js +154 -0
- package/templates/webterm/typed-line.js +70 -7
- package/templates/context/context-usage.md +0 -1
- package/templates/context-usage.sh +0 -266
- package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
- package/templates/skills/dx-spec-config/SKILL.md +0 -313
- package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
- package/templates/specs.sh +0 -106
- package/templates/webterm/specs.js +0 -358
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// tool-host.js - a pane tool's client, in a frame of its own.
|
|
2
|
+
//
|
|
3
|
+
// A 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 that tool's own reading at worst. This module is the host around such a frame: it owns the panel,
|
|
6
|
+
// the frame, 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 it. The frame gets a tool-scoped key that opens that tool's data routes
|
|
9
|
+
// and a path prefix that opens the files it serves, 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
|
+
// One host per tool, made by that tool's own module beside this file, which names the tool and nothing more.
|
|
15
|
+
// Everything here is the same for every tool, so a third one is a panel and three lines.
|
|
16
|
+
|
|
17
|
+
import { sendFrame } from "./connection.js";
|
|
18
|
+
import { noteTool, registerTool, rememberPaneTheme, storedPaneTheme } from "./pane-shell.js";
|
|
19
|
+
import { attachedSid, KEY_HEADER, WEB_KEY } from "./state.js";
|
|
20
|
+
|
|
21
|
+
// How many times a frame that navigates itself is rebuilt before the tab says so instead. A client that keeps
|
|
22
|
+
// leaving the document it was given is broken or is being driven, and either way rebuilding it for ever is a
|
|
23
|
+
// spinning window rather than a recovery.
|
|
24
|
+
const MAX_REBUILDS = 3;
|
|
25
|
+
|
|
26
|
+
// What the shell and a frame call each other's messages. Namespaced by the tool, because a frame may be
|
|
27
|
+
// talking to something else of its own, and every message is checked against the frame it must come from.
|
|
28
|
+
const HANDOVER = "credentials";
|
|
29
|
+
const CHANGE = "change";
|
|
30
|
+
const OPEN = "open";
|
|
31
|
+
const DELIVER = "deliver";
|
|
32
|
+
const WAITING = "waiting";
|
|
33
|
+
const DELIVERED = "delivered";
|
|
34
|
+
const SESSION = "session";
|
|
35
|
+
const THEME = "theme";
|
|
36
|
+
|
|
37
|
+
// id -> the host of that tool, so a frame from the server reaches the one it is about.
|
|
38
|
+
const hosts = new Map();
|
|
39
|
+
|
|
40
|
+
/** The host of one tool, or undefined. */
|
|
41
|
+
export function toolHost(id) {
|
|
42
|
+
return hosts.get(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tell every tool which session this window is now driving. */
|
|
46
|
+
export function toolsMovedToSession() {
|
|
47
|
+
for (const host of hosts.values()) host.session();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Put one tool on the page.
|
|
52
|
+
*
|
|
53
|
+
* `id` is the tool's own name, which is both the prefix of its routes and the name of its messages - the
|
|
54
|
+
* server derives the same paths from the same word (see `tools/paths.js`). `panel` is the element the pane
|
|
55
|
+
* gave this tool, and `opening` is the one sentence shown while its credentials are being fetched.
|
|
56
|
+
*/
|
|
57
|
+
export function createToolHost({ id, label, panel, opening }) {
|
|
58
|
+
// Where the built client is served from, and where the shell asks for the frame's credentials. The second
|
|
59
|
+
// one is behind the master key in a header, which is the shell's own gate and never the frame's.
|
|
60
|
+
const FRAME_SRC = `/pane-apps/${id}/index.html`;
|
|
61
|
+
const KEYS_URL = `/${id}/keys`;
|
|
62
|
+
const named = (what) => `${id}:${what}`;
|
|
63
|
+
|
|
64
|
+
let frame = null;
|
|
65
|
+
let handed = false;
|
|
66
|
+
let rebuilds = 0;
|
|
67
|
+
let credentials = null;
|
|
68
|
+
let asking = false;
|
|
69
|
+
let failure = null;
|
|
70
|
+
// How many things are waiting for the user inside the tool, and whether the tool has anything to show at
|
|
71
|
+
// all. Kept here rather than in the frame, because both have to hold while another tool is open and the
|
|
72
|
+
// frame is not being drawn. A tool that has said nothing yet has something to show: a panel drawn once
|
|
73
|
+
// and then declared empty is a pane that moves under a reader who did nothing.
|
|
74
|
+
let waiting = 0;
|
|
75
|
+
let anything = true;
|
|
76
|
+
|
|
77
|
+
/** Fetch the frame's credentials. The shell holds the master key; this is the only route it opens for this tool. */
|
|
78
|
+
async function fetchCredentials() {
|
|
79
|
+
if (asking) return;
|
|
80
|
+
asking = true;
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(KEYS_URL, { headers: { [KEY_HEADER]: WEB_KEY }, cache: "no-store" });
|
|
83
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
84
|
+
const answer = await response.json();
|
|
85
|
+
if (typeof answer?.key !== "string" || typeof answer?.raw !== "string") throw new Error("no credentials");
|
|
86
|
+
credentials = answer;
|
|
87
|
+
failure = null;
|
|
88
|
+
} catch {
|
|
89
|
+
failure = `The ${label} tool could not be reached. The next time this tab is opened it tries again.`;
|
|
90
|
+
} finally {
|
|
91
|
+
asking = false;
|
|
92
|
+
}
|
|
93
|
+
// Drawn whether the tab is open or not. The panel is off the screen when it is not, so nothing is
|
|
94
|
+
// shown either way, but this is the step that puts the frame up - and the tool has to be running to
|
|
95
|
+
// say what is waiting for the user, wherever in the pane that user happens to be.
|
|
96
|
+
render();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Put a fresh frame in the panel, replacing whatever was there. */
|
|
100
|
+
function build() {
|
|
101
|
+
const next = document.createElement("iframe");
|
|
102
|
+
next.className = "tool-frame";
|
|
103
|
+
// The whole boundary in one attribute: scripts and forms, and no same-origin, so the document has an
|
|
104
|
+
// opaque origin and nothing of this window is reachable from inside it - not the DOM, not storage,
|
|
105
|
+
// and not the URL that carries the master key. Set before the src, since the sandbox of a frame that
|
|
106
|
+
// already has a document is not what a later attribute says it is.
|
|
107
|
+
next.setAttribute("sandbox", "allow-scripts allow-forms");
|
|
108
|
+
// A frame inherits its embedder's referrer, and this window's URL carries the master key - both as
|
|
109
|
+
// `document.referrer` inside the frame and as the `Referer` header on everything it loads.
|
|
110
|
+
next.setAttribute("referrerpolicy", "no-referrer");
|
|
111
|
+
next.setAttribute("title", label);
|
|
112
|
+
next.addEventListener("load", () => onLoad(next));
|
|
113
|
+
next.src = FRAME_SRC;
|
|
114
|
+
frame = next;
|
|
115
|
+
handed = false;
|
|
116
|
+
panel.replaceChildren(next);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The frame finished loading a document. The first one is the client; a second one is a navigation. */
|
|
120
|
+
function onLoad(loaded) {
|
|
121
|
+
if (loaded !== frame) return;
|
|
122
|
+
if (!handed) {
|
|
123
|
+
handed = true;
|
|
124
|
+
loaded.contentWindow?.postMessage({ t: named(HANDOVER), key: credentials.key, raw: credentials.raw }, "*");
|
|
125
|
+
// The session this window is on, right behind the handover. The frame usually finishes loading
|
|
126
|
+
// after the first session has attached, so without this a window that never switches would never
|
|
127
|
+
// hear which session it is beside - and a tool keeps its view per session.
|
|
128
|
+
sendSession(loaded);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (rebuilds >= MAX_REBUILDS) {
|
|
132
|
+
frame = null;
|
|
133
|
+
failure = `The ${label} tool kept leaving the page it was given, so it was stopped.`;
|
|
134
|
+
render();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
rebuilds += 1;
|
|
138
|
+
build();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One sentence in the middle of the panel: what the tab is doing, or why it is doing nothing. */
|
|
142
|
+
function say(line) {
|
|
143
|
+
const wrap = document.createElement("div");
|
|
144
|
+
wrap.className = "pane-intro";
|
|
145
|
+
const title = document.createElement("h2");
|
|
146
|
+
title.textContent = label;
|
|
147
|
+
const lede = document.createElement("p");
|
|
148
|
+
lede.textContent = line;
|
|
149
|
+
wrap.append(title, lede);
|
|
150
|
+
panel.replaceChildren(wrap);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function render() {
|
|
154
|
+
// What the tool last said about itself, which holds while another tool is open: what is waiting for
|
|
155
|
+
// the user in it, and whether it has anything to show.
|
|
156
|
+
noteTool(id, { unread: waiting, empty: !anything });
|
|
157
|
+
if (failure !== null) {
|
|
158
|
+
// Said once and then forgotten, so coming back to the tab is what tries again.
|
|
159
|
+
const line = failure;
|
|
160
|
+
failure = null;
|
|
161
|
+
frame = null;
|
|
162
|
+
say(line);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// A frame that is up is left alone: it holds the tool's whole state, and rebuilding it would be a
|
|
166
|
+
// reload every time the user came back to the tab.
|
|
167
|
+
if (frame !== null) return;
|
|
168
|
+
if (credentials === null) {
|
|
169
|
+
say(opening);
|
|
170
|
+
fetchCredentials();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
build();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Tell one frame which session this window is driving, and how that session reads. `null` says there is none.
|
|
178
|
+
*
|
|
179
|
+
* The theme rides with the session because it belongs to it: the frame has an opaque origin and no storage
|
|
180
|
+
* of its own, so what it was set to last is the pane's to keep and the pane's to hand back.
|
|
181
|
+
*/
|
|
182
|
+
function sendSession(target) {
|
|
183
|
+
const sid = attachedSid ?? null;
|
|
184
|
+
// Both fields say the same session or neither does: the pane is drawn in the session it opened with
|
|
185
|
+
// before the first frame names one, and handing the frame that session's palette beside "there is no
|
|
186
|
+
// session" would have it read in a theme it was never told the reason for.
|
|
187
|
+
target?.contentWindow?.postMessage({ t: named(SESSION), sid, theme: sid === null ? null : storedPaneTheme() }, "*");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether a message is this tool asking for something, rather than anything else on this window.
|
|
192
|
+
*
|
|
193
|
+
* Two checks, and each one holds without the other. The sender must be the frame's own window, which
|
|
194
|
+
* refuses a workspace document nested inside the tool - it is a frame of its own and can post here. And
|
|
195
|
+
* the message must carry the tool's key, which only the document this shell loaded ever received, so a
|
|
196
|
+
* document that navigated the frame passes the window check and fails this one.
|
|
197
|
+
*/
|
|
198
|
+
function fromTool(event, message) {
|
|
199
|
+
if (frame === null || !handed || credentials === null) return false;
|
|
200
|
+
if (event.source !== frame.contentWindow) return false;
|
|
201
|
+
return typeof message.key === "string" && message.key === credentials.key;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A link the tool will not follow itself. The frame has to stay the document this shell put in it, since a
|
|
205
|
+
// navigation would keep the window identity every check here rests on, so an outside link is opened as a
|
|
206
|
+
// window of this interface instead - the web alone, and with nothing of this window carried into it.
|
|
207
|
+
window.addEventListener("message", (event) => {
|
|
208
|
+
const message = event.data;
|
|
209
|
+
if (!message || typeof message !== "object" || message.t !== named(OPEN)) return;
|
|
210
|
+
if (!fromTool(event, message)) return;
|
|
211
|
+
let url;
|
|
212
|
+
try {
|
|
213
|
+
url = new URL(String(message.url));
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return;
|
|
218
|
+
window.open(url.href, "_blank", "noopener,noreferrer");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The tool saying what it is waiting on, and whether it holds anything. Both are the tool's own; the mark
|
|
222
|
+
// they put on the tab is the shell's - which is what makes something awaiting an answer visible from any
|
|
223
|
+
// other tool in the pane. Only the word "no" makes a tool empty, so a message from an older client that
|
|
224
|
+
// says nothing about it leaves the tab as it was.
|
|
225
|
+
window.addEventListener("message", (event) => {
|
|
226
|
+
const message = event.data;
|
|
227
|
+
if (!message || typeof message !== "object" || message.t !== named(WAITING)) return;
|
|
228
|
+
if (!fromTool(event, message)) return;
|
|
229
|
+
const count = Number(message.count);
|
|
230
|
+
waiting = Number.isFinite(count) && count > 0 ? Math.min(Math.floor(count), 99) : 0;
|
|
231
|
+
anything = message.anything !== false;
|
|
232
|
+
noteTool(id, { unread: waiting, empty: !anything });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// The tool asking for the other palette. It has nowhere to keep the answer, so the pane keeps it with the
|
|
236
|
+
// rest of that session's shape and hands it straight back down the session path - which is the one way the
|
|
237
|
+
// frame ever learns its theme, so the store is what the tool is reading rather than a copy of it.
|
|
238
|
+
window.addEventListener("message", (event) => {
|
|
239
|
+
const message = event.data;
|
|
240
|
+
if (!message || typeof message !== "object" || message.t !== named(THEME)) return;
|
|
241
|
+
if (!fromTool(event, message)) return;
|
|
242
|
+
rememberPaneTheme(message.theme);
|
|
243
|
+
sendSession(frame);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// A delivery: the tool telling the agent beside it that something arrived. It rides this window's own
|
|
247
|
+
// socket, so the server resolves the session from the socket the way every other pane frame does, and
|
|
248
|
+
// nothing here or in the frame names one. What travels is which tool it is from, the kind of event, a line
|
|
249
|
+
// of detail, the file the tool wrote, and whether the user agreed to start a session for it.
|
|
250
|
+
window.addEventListener("message", (event) => {
|
|
251
|
+
const message = event.data;
|
|
252
|
+
if (!message || typeof message !== "object" || message.t !== named(DELIVER)) return;
|
|
253
|
+
if (!fromTool(event, message)) return;
|
|
254
|
+
sendFrame({
|
|
255
|
+
t: "tool:deliver",
|
|
256
|
+
tool: id,
|
|
257
|
+
event: String(message.event ?? ""),
|
|
258
|
+
detail: String(message.detail ?? ""),
|
|
259
|
+
path: String(message.path ?? ""),
|
|
260
|
+
create: message.create === true,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const host = {
|
|
265
|
+
/** The disk moved under this tool's work. Passed straight in: the frame decides what is worth re-reading. */
|
|
266
|
+
change(msg) {
|
|
267
|
+
if (frame === null || !handed) return;
|
|
268
|
+
frame.contentWindow?.postMessage({ t: named(CHANGE), change: msg.change }, "*");
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The window moved to another session, or off the one it had.
|
|
273
|
+
*
|
|
274
|
+
* A tool keeps one view per session - what is open, where the reader is, and what they are holding -
|
|
275
|
+
* so a switch has to reach it. It rides the same path as every other message to the frame, and it
|
|
276
|
+
* names the session this window is on rather than one the frame may choose.
|
|
277
|
+
*/
|
|
278
|
+
session() {
|
|
279
|
+
if (frame === null || !handed) return;
|
|
280
|
+
sendSession(frame);
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/** What the server made of the last delivery, passed back to the frame that asked for it. */
|
|
284
|
+
delivered(msg) {
|
|
285
|
+
if (frame === null || !handed) return;
|
|
286
|
+
frame.contentWindow?.postMessage({ t: named(DELIVERED), ok: msg.ok === true, error: msg.error ?? "" }, "*");
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
hosts.set(id, host);
|
|
291
|
+
registerTool(id, render);
|
|
292
|
+
|
|
293
|
+
// The frame is built at startup rather than the first time the tab is opened, because the mark on the tab
|
|
294
|
+
// is the tool's to put there and the tool cannot say anything until it is running. Waiting for the first
|
|
295
|
+
// open would make "something is waiting for you" visible only to a user who already went and looked,
|
|
296
|
+
// which is the one reader who did not need telling - and a window reopened on another tool would show
|
|
297
|
+
// nothing at all. Nothing is drawn by this: the panel is off the screen until its tab is picked, and the
|
|
298
|
+
// frame holds the tool's state from here on, so opening the tab shows what is already there instead of
|
|
299
|
+
// loading it then.
|
|
300
|
+
render();
|
|
301
|
+
return host;
|
|
302
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// workspace-color.js - the control in the bar that says what colour this workspace wears, and changes it.
|
|
2
|
+
//
|
|
3
|
+
// The colour is the one thing a window says about itself from across a screen full of windows, and until now
|
|
4
|
+
// it was worked out from the published port with nothing the user could do about it. The control is a palette
|
|
5
|
+
// glyph beside the bell, drawn and coloured exactly as the bell is, so the row stays a row.
|
|
6
|
+
//
|
|
7
|
+
// Everything is one click. Picking a colour applies it at once, with nothing to confirm, because there is
|
|
8
|
+
// nothing to get wrong - the panel offers eleven colours and a way back to automatic, and a colour that turns
|
|
9
|
+
// out to be the wrong one is another click. The mark stays on what is *stored*, not on what is on the screen,
|
|
10
|
+
// so "nothing picked" is still tellable from "picked the colour the port would have given anyway".
|
|
11
|
+
//
|
|
12
|
+
// The panel hangs off the body through anchored.js. It is not a nicety: renderBar() empties the bar on every
|
|
13
|
+
// frame, and picking a colour broadcasts a frame back to this very window, so a panel inside the bar would
|
|
14
|
+
// close itself the moment it was used.
|
|
15
|
+
|
|
16
|
+
import { createAnchoredPanel } from "./anchored.js";
|
|
17
|
+
import { connected, sendFrame } from "./connection.js";
|
|
18
|
+
import { SVG_NS } from "./dom.js";
|
|
19
|
+
import { hexFor, PALETTE } from "./palette.js";
|
|
20
|
+
import { workspaceColorId } from "./state.js";
|
|
21
|
+
|
|
22
|
+
const BUTTON_ID = "wscolorbtn";
|
|
23
|
+
|
|
24
|
+
// markButton runs on every open and every close, so the button says what the panel is doing however the
|
|
25
|
+
// panel got there - a choice made in it, a click outside, Escape, or the socket dropping.
|
|
26
|
+
const panel = createAnchoredPanel(BUTTON_ID, () => markButton());
|
|
27
|
+
|
|
28
|
+
// Put the open panel back under its button after the bar was rebuilt, and take it away with a button that has
|
|
29
|
+
// gone. Called from renderBar(), beside the new-session panel's own.
|
|
30
|
+
export function repositionColorPop() {
|
|
31
|
+
panel.reposition();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The tick on the chosen cell. Drawn rather than styled so it sits on any of the eleven: the stroke is the
|
|
35
|
+
// interface's own near-black, which every colour in the palette carries at 3:1 or better.
|
|
36
|
+
function tick() {
|
|
37
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
38
|
+
svg.setAttribute("viewBox", "0 0 10 8");
|
|
39
|
+
svg.setAttribute("width", "10");
|
|
40
|
+
svg.setAttribute("height", "8");
|
|
41
|
+
svg.setAttribute("aria-hidden", "true");
|
|
42
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
43
|
+
path.setAttribute("d", "M1 4.2 3.6 6.8 9 1.2");
|
|
44
|
+
path.setAttribute("fill", "none");
|
|
45
|
+
path.setAttribute("stroke", "#0d1117");
|
|
46
|
+
path.setAttribute("stroke-width", "2");
|
|
47
|
+
path.setAttribute("stroke-linecap", "round");
|
|
48
|
+
path.setAttribute("stroke-linejoin", "round");
|
|
49
|
+
svg.append(path);
|
|
50
|
+
return svg;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// One cell of the grid. `id` is "" for the automatic cell, which is one of the choices rather than a button
|
|
54
|
+
// under them: it is what the workspace goes back to, so it belongs in the same grid as what it replaces.
|
|
55
|
+
function cell(id, hex, label, stored) {
|
|
56
|
+
const button = document.createElement("button");
|
|
57
|
+
button.type = "button";
|
|
58
|
+
button.className = id === "" ? "wscell auto" : "wscell";
|
|
59
|
+
button.title = label;
|
|
60
|
+
// Plain buttons that say whether they are the one in force. Not role="radio": that role promises arrow-key
|
|
61
|
+
// navigation with a roving tabindex, and describing behaviour the code does not have is worse than saying
|
|
62
|
+
// less. Every cell stays in the tab order, which is what actually happens.
|
|
63
|
+
const chosen = id === stored;
|
|
64
|
+
button.setAttribute("aria-pressed", String(chosen));
|
|
65
|
+
button.setAttribute("aria-label", label);
|
|
66
|
+
if (id === "") button.textContent = "A";
|
|
67
|
+
else button.style.background = hex;
|
|
68
|
+
if (chosen) {
|
|
69
|
+
button.classList.add("chosen");
|
|
70
|
+
// The ring carries the mark on every cell. The tick goes on the colours alone: the automatic cell
|
|
71
|
+
// already holds its "A", and a tick beside it would sit off centre and be drawn in a near-black that
|
|
72
|
+
// is all but invisible on that cell's own dark stripes.
|
|
73
|
+
if (id !== "") button.append(tick());
|
|
74
|
+
}
|
|
75
|
+
button.addEventListener("click", () => {
|
|
76
|
+
panel.close();
|
|
77
|
+
// Sent, never held: the server decides, and every window is told on the frame that comes back. A pick
|
|
78
|
+
// kept to send later would be undone by the next sessions frame anyway (FR-17).
|
|
79
|
+
sendFrame({ t: "color", color: id });
|
|
80
|
+
});
|
|
81
|
+
return button;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function popLabel(text) {
|
|
85
|
+
const label = document.createElement("span");
|
|
86
|
+
label.className = "poplabel";
|
|
87
|
+
label.textContent = text;
|
|
88
|
+
return label;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function openPanel() {
|
|
92
|
+
// What is *stored*, and an id this version does not know counts as nothing stored - the same reading
|
|
93
|
+
// theme.js gives it when it paints. Without that, a colour dropped from the palette in a later version
|
|
94
|
+
// would leave the panel with no mark at all: no cell matches the id, and the automatic cell would not
|
|
95
|
+
// either, though automatic is exactly what the window is showing.
|
|
96
|
+
const stored = hexFor(workspaceColorId) ? workspaceColorId : "";
|
|
97
|
+
const pop = document.createElement("div");
|
|
98
|
+
pop.id = "wscolorpop";
|
|
99
|
+
pop.setAttribute("role", "dialog");
|
|
100
|
+
pop.setAttribute("aria-label", "Workspace colour");
|
|
101
|
+
|
|
102
|
+
const grid = document.createElement("div");
|
|
103
|
+
grid.className = "wsgrid";
|
|
104
|
+
grid.setAttribute("role", "group");
|
|
105
|
+
grid.setAttribute("aria-label", "Workspace colour");
|
|
106
|
+
for (const colour of PALETTE) grid.append(cell(colour.id, colour.hex, colour.id, stored));
|
|
107
|
+
// Automatic last, so the eleven read as one set and the way out sits at the end of it.
|
|
108
|
+
grid.append(cell("", "", "Automatic", stored));
|
|
109
|
+
|
|
110
|
+
const foot = document.createElement("p");
|
|
111
|
+
foot.className = "wscolorfoot";
|
|
112
|
+
foot.textContent = "Automatic goes back to the colour this workspace works out for itself.";
|
|
113
|
+
|
|
114
|
+
pop.append(popLabel("Workspace colour"), grid, foot);
|
|
115
|
+
panel.open(pop);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function togglePanel() {
|
|
119
|
+
if (panel.isOpen()) panel.close();
|
|
120
|
+
else openPanel();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The open state, said on the button as it happens rather than left to the next bar rebuild, which may be a
|
|
124
|
+
// frame away or the whole sixty-second age tick away: a screen reader would otherwise be told "collapsed" over
|
|
125
|
+
// an open panel. The bar is rebuilt from scratch on every frame, so this looks the button up rather than
|
|
126
|
+
// holding one, and does nothing when the bar has not drawn yet.
|
|
127
|
+
function markButton() {
|
|
128
|
+
const button = document.getElementById(BUTTON_ID);
|
|
129
|
+
if (!button) return;
|
|
130
|
+
button.setAttribute("aria-expanded", String(panel.isOpen()));
|
|
131
|
+
button.classList.toggle("open", panel.isOpen());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Called when the socket drops, so the panel goes with it. renderBar() rebuilds the button disabled, but a
|
|
135
|
+
// panel that is already open hangs off the body, where the bar's own offline rules cannot reach it.
|
|
136
|
+
export function closeColorPop() {
|
|
137
|
+
panel.close();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A palette: a circle with a bite out of it for the thumb hole, and three wells of paint. Drawn in the idiom
|
|
141
|
+
// the bell and the power button use - a 24-unit grid at 14px, no fill, currentColor at stroke-width 2 - so
|
|
142
|
+
// the three read as one row of controls. The wells are filled rather than stroked, because a ring of one
|
|
143
|
+
// unit closes up at this size.
|
|
144
|
+
function paletteGlyph() {
|
|
145
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
146
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
147
|
+
svg.setAttribute("width", "14");
|
|
148
|
+
svg.setAttribute("height", "14");
|
|
149
|
+
svg.setAttribute("fill", "none");
|
|
150
|
+
svg.setAttribute("stroke", "currentColor");
|
|
151
|
+
svg.setAttribute("stroke-width", "2");
|
|
152
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
153
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
154
|
+
const body = document.createElementNS(SVG_NS, "path");
|
|
155
|
+
body.setAttribute("d", "M12 22a10 10 0 1 1 10-10c0 2.2-1.8 4-4 4h-1.6a2.4 2.4 0 0 0-1.7 4.1A2 2 0 0 1 12 22z");
|
|
156
|
+
svg.append(body);
|
|
157
|
+
for (const [cx, cy] of [
|
|
158
|
+
[7.5, 12.5],
|
|
159
|
+
[9.5, 8],
|
|
160
|
+
[14.5, 7.5],
|
|
161
|
+
]) {
|
|
162
|
+
const well = document.createElementNS(SVG_NS, "circle");
|
|
163
|
+
well.setAttribute("cx", String(cx));
|
|
164
|
+
well.setAttribute("cy", String(cy));
|
|
165
|
+
well.setAttribute("r", "1");
|
|
166
|
+
well.setAttribute("fill", "currentColor");
|
|
167
|
+
well.setAttribute("stroke", "none");
|
|
168
|
+
svg.append(well);
|
|
169
|
+
}
|
|
170
|
+
return svg;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The control that opens the palette. It carries no colour of its own: the bar already says which colour is
|
|
174
|
+
// in force, on the edge of the active tab, and a filled swatch in a row of glyphs reads as a thing that
|
|
175
|
+
// wandered in rather than as one of the row.
|
|
176
|
+
export function workspaceColorButton() {
|
|
177
|
+
const button = document.createElement("button");
|
|
178
|
+
button.id = BUTTON_ID;
|
|
179
|
+
button.type = "button";
|
|
180
|
+
// Nothing to open, and nothing to send, while this window is not talking to the server: a pick that could
|
|
181
|
+
// not be sent would be undone by the next frame, which is worse than not offering it. The socket dropping
|
|
182
|
+
// is what closes an open panel, in connection.js - by the time this runs the panel is already gone.
|
|
183
|
+
button.disabled = !connected;
|
|
184
|
+
button.title = "The colour this workspace wears";
|
|
185
|
+
button.setAttribute("aria-label", "Change the workspace colour");
|
|
186
|
+
button.setAttribute("aria-haspopup", "dialog");
|
|
187
|
+
button.setAttribute("aria-expanded", String(panel.isOpen()));
|
|
188
|
+
button.classList.toggle("open", panel.isOpen());
|
|
189
|
+
button.append(paletteGlyph());
|
|
190
|
+
button.addEventListener("click", togglePanel);
|
|
191
|
+
return button;
|
|
192
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<div id="middle">
|
|
22
22
|
<div id="termwrap">
|
|
23
23
|
<main id="term"></main>
|
|
24
|
-
<!-- Take over / end session / limit reached,
|
|
24
|
+
<!-- Take over / end session / limit reached, fixed to the viewport so the card is in the middle of the screen. -->
|
|
25
25
|
<div id="overlay"><div id="card" class="card"></div></div>
|
|
26
26
|
<!-- Transient status ("copied 3 lines", upload and dictation errors), floating so it shifts nothing. -->
|
|
27
27
|
<span id="note"></span>
|
|
@@ -45,6 +45,14 @@
|
|
|
45
45
|
<path d="M13 8l-4 4 4 4" />
|
|
46
46
|
</svg>
|
|
47
47
|
</button>
|
|
48
|
+
<!-- Two equal panels, split down the middle: the pane at half the window, which is the
|
|
49
|
+
width it opens at. -->
|
|
50
|
+
<button type="button" id="pane-half" class="pane-nav" title="Half the window" aria-label="Half the window">
|
|
51
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
52
|
+
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
|
53
|
+
<path d="M12 5v14" />
|
|
54
|
+
</svg>
|
|
55
|
+
</button>
|
|
48
56
|
<!-- An arrow into the pane's own edge: put it away, that way. Inline because this bar is
|
|
49
57
|
static chrome, unlike the session bar's icons, which are drawn per frame. -->
|
|
50
58
|
<button type="button" id="pane-collapse" class="pane-nav" title="Collapse the pane" aria-label="Collapse the pane">
|
|
@@ -79,8 +87,14 @@
|
|
|
79
87
|
<!-- Specs: the spec workspace, in a frame of its own. -->
|
|
80
88
|
<div id="tool-specs" class="tool-panel" hidden></div>
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
<!-- Code Reviews: the reviews of this repository, in a frame of its own. -->
|
|
91
|
+
<div id="tool-reviews" class="tool-panel" hidden></div>
|
|
92
|
+
|
|
93
|
+
<!-- The label names what the pane holds now that it holds more than pages: one tab per app.
|
|
94
|
+
The `webpane` helper, the framed line a page's feedback arrives on, and the skill are all
|
|
95
|
+
named for the pane itself and are unchanged - they are an agent's interface, not a label. -->
|
|
96
|
+
<button type="button" id="spine" title="Open the apps pane" aria-label="Open the apps pane">
|
|
97
|
+
<span class="label"><span id="spine-badge" hidden></span> Apps pane</span>
|
|
84
98
|
</button>
|
|
85
99
|
</aside>
|
|
86
100
|
</div>
|