chamba 0.6.1 → 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 +38 -11
- 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 -184
- 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 -260
- 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 -26
- 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 +6 -6
- package/templates/Dockerfile +24 -12
- 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 +14 -0
- package/templates/pane-apps/server/reviews.mjs +20 -0
- package/templates/pane-apps/server/specs.mjs +5 -0
- 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 +561 -0
- package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
- package/templates/skills/dx-spec/references/principles-template.md +2 -0
- package/templates/skills/dx-spec/references/review-guide.md +127 -0
- package/templates/skills/dx-spec/references/spec-guide.md +145 -0
- package/templates/skills/dx-spec/references/stages.md +484 -0
- package/templates/skills/dx-spec-execute/SKILL.md +437 -0
- package/templates/startup.mjs +10 -4
- package/templates/tool-helper.sh +166 -0
- package/templates/webterm/README.md +79 -9
- package/templates/webterm/color.js +61 -0
- package/templates/webterm/config.js +74 -0
- 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/composer.js +4 -1
- package/templates/webterm/public/app/connection.js +9 -0
- package/templates/webterm/public/app/dom.js +15 -5
- package/templates/webterm/public/app/frames.js +51 -10
- package/templates/webterm/public/app/main.js +26 -6
- 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 +380 -0
- package/templates/webterm/public/app/pane.js +77 -193
- package/templates/webterm/public/app/reviews-host.js +15 -0
- package/templates/webterm/public/app/specs-host.js +16 -0
- package/templates/webterm/public/app/state.js +5 -0
- package/templates/webterm/public/app/tabs.js +6 -2
- package/templates/webterm/public/app/terminal.js +8 -0
- 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 +68 -30
- package/templates/webterm/public/styles.css +270 -34
- package/templates/webterm/server.js +321 -0
- package/templates/webterm/tool-document.js +67 -0
- 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 +148 -0
- package/templates/context/context-usage.md +0 -1
- package/templates/context-usage.sh +0 -266
|
@@ -1,58 +1,46 @@
|
|
|
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
|
+
isToolOpen,
|
|
35
|
+
noteTool,
|
|
36
|
+
PAGES,
|
|
37
|
+
registerTool,
|
|
38
|
+
rememberPageSize,
|
|
39
|
+
setPaneCollapsed,
|
|
40
|
+
storedPageSize,
|
|
41
|
+
} from "./pane-shell.js";
|
|
42
42
|
import { attachedSid } from "./state.js";
|
|
43
43
|
|
|
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
44
|
// The sizes a page can be shown at, smallest first, and where an untouched window starts. The spacing is
|
|
57
45
|
// deliberately uneven: a correction near normal reading size wants a fine step, and someone going to 150%
|
|
58
46
|
// wants to get there in two presses.
|
|
@@ -66,39 +54,16 @@ const AGE_TICK_MS = 30_000;
|
|
|
66
54
|
// heard about, so switching back to a tab lands on the page it was left on.
|
|
67
55
|
const panes = new Map();
|
|
68
56
|
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
}
|
|
57
|
+
// Which of TEXT_SIZES the shown page is drawn at. It belongs to the session, so it is asked of the pane each
|
|
58
|
+
// time rather than held here: a switch changes the answer. The multiplier is what the pane stores, so a
|
|
59
|
+
// session left at a size this version no longer offers falls back to the default instead of landing between
|
|
60
|
+
// two steps.
|
|
61
|
+
function sizeStep() {
|
|
62
|
+
const at = TEXT_SIZES.indexOf(storedPageSize());
|
|
63
|
+
return at === -1 ? DEFAULT_SIZE_STEP : at;
|
|
99
64
|
}
|
|
100
65
|
|
|
101
|
-
// --- The
|
|
66
|
+
// --- The tool's own state ----------------------------------------------------------------------------------------------------------------
|
|
102
67
|
|
|
103
68
|
function paneFor(sid) {
|
|
104
69
|
let entry = panes.get(sid);
|
|
@@ -122,14 +87,15 @@ export function applyPages(msg) {
|
|
|
122
87
|
// The page this frame opens, if it opens one - the rule itself is in pane-arrival.js, where it can be run,
|
|
123
88
|
// and so is what a burst of publishes does under it.
|
|
124
89
|
const arrived = arrivingPage(entry.pages);
|
|
90
|
+
// It is selected either way, so the tab the user comes back to is on the page that arrived. Only the
|
|
91
|
+
// saying-so is held back while another tool has the pane.
|
|
125
92
|
if (arrived !== null) {
|
|
126
93
|
select(msg.sid, arrived.id, { user: false });
|
|
127
|
-
|
|
94
|
+
if (isToolOpen(PAGES)) flashPane();
|
|
128
95
|
}
|
|
129
|
-
// A pane someone put away comes back for a page
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
if (arrived !== null && collapsed === true) setCollapsed(false);
|
|
96
|
+
// A pane someone put away comes back for a page, in the session the page was published in - which is
|
|
97
|
+
// the session this frame is about, since the list only reaches the tool for the one it is driving.
|
|
98
|
+
if (arrived !== null && isToolOpen(PAGES) && isPaneCollapsed()) setPaneCollapsed(false);
|
|
133
99
|
else render();
|
|
134
100
|
}
|
|
135
101
|
|
|
@@ -141,7 +107,7 @@ export function prunePanes(live) {
|
|
|
141
107
|
}
|
|
142
108
|
|
|
143
109
|
/**
|
|
144
|
-
* Draw the
|
|
110
|
+
* Draw the tool for whatever session this window is now on, asking for its pages when this window has never
|
|
145
111
|
* been told them. The server pushes the list when a window lands on a session, so this covers the frame that
|
|
146
112
|
* did not arrive - a socket replaced mid-attach, or a window that took a session over from another one.
|
|
147
113
|
*/
|
|
@@ -207,7 +173,7 @@ function introState() {
|
|
|
207
173
|
const wrap = document.createElement("div");
|
|
208
174
|
wrap.className = "pane-intro";
|
|
209
175
|
const title = document.createElement("h2");
|
|
210
|
-
title.textContent = "
|
|
176
|
+
title.textContent = "Pages";
|
|
211
177
|
const lede = document.createElement("p");
|
|
212
178
|
lede.textContent = "Ask the agent for a page whenever the answer is longer or more structured than the terminal carries well:";
|
|
213
179
|
const asks = document.createElement("ul");
|
|
@@ -257,79 +223,49 @@ function chipFor(sid, page, selected) {
|
|
|
257
223
|
return chip;
|
|
258
224
|
}
|
|
259
225
|
|
|
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
226
|
// --- How big a page is drawn -------------------------------------------------------------------------------------------------------------
|
|
271
227
|
|
|
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
|
|
228
|
+
// The size is a property on this tool's panel, and the stylesheet zooms the frame from it. Two reasons, both
|
|
229
|
+
// structural: the frame's document has an opaque origin and the shell cannot reach into it, and renderPage()
|
|
230
|
+
// throws the iframe away and builds a fresh one on every chip click, so anything set on the frame itself
|
|
231
|
+
// would have to be put back on every one of those paths. Setting it out here costs nothing and cannot be
|
|
232
|
+
// missed.
|
|
276
233
|
function applyTextSize() {
|
|
277
|
-
const
|
|
278
|
-
|
|
234
|
+
const step = sizeStep();
|
|
235
|
+
const size = TEXT_SIZES[step];
|
|
236
|
+
toolPages.style.setProperty("--page-zoom", String(size));
|
|
279
237
|
pageSizeVal.textContent = `${Math.round(size * 100)}%`;
|
|
280
238
|
// A control that can do nothing says so, rather than swallowing the press.
|
|
281
|
-
pageSmaller.disabled =
|
|
282
|
-
pageBigger.disabled =
|
|
239
|
+
pageSmaller.disabled = step === 0;
|
|
240
|
+
pageBigger.disabled = step === TEXT_SIZES.length - 1;
|
|
283
241
|
}
|
|
284
242
|
|
|
285
243
|
function stepTextSize(by) {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
244
|
+
const step = sizeStep();
|
|
245
|
+
const next = Math.min(Math.max(step + by, 0), TEXT_SIZES.length - 1);
|
|
246
|
+
if (next === step) return;
|
|
247
|
+
rememberPageSize(TEXT_SIZES[next]);
|
|
289
248
|
applyTextSize();
|
|
290
|
-
rememberGeometry();
|
|
291
249
|
}
|
|
292
250
|
|
|
293
251
|
pageSmaller.addEventListener("click", () => stepTextSize(-1));
|
|
294
252
|
pageBigger.addEventListener("click", () => stepTextSize(1));
|
|
295
|
-
applyTextSize();
|
|
296
|
-
|
|
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
253
|
|
|
316
254
|
function render() {
|
|
255
|
+
// The zoom is the session's, so it is applied wherever this tool draws rather than once at load: the
|
|
256
|
+
// first draw is already in a session's shape, and every switch draws again.
|
|
257
|
+
applyTextSize();
|
|
317
258
|
const sid = attachedSid;
|
|
318
259
|
const entry = sid === null ? null : (panes.get(sid) ?? null);
|
|
319
260
|
const pages = entry?.pages ?? [];
|
|
320
261
|
const unread = pages.filter((page) => page.unread).length;
|
|
321
262
|
|
|
322
|
-
// Nothing published in this session, and nothing to say about it. The
|
|
323
|
-
//
|
|
324
|
-
//
|
|
263
|
+
// Nothing published in this session, and nothing to say about it. The tab stays and the pane stays open,
|
|
264
|
+
// because a pane that only appears once an agent has used it is one nobody knows to ask for. What the
|
|
265
|
+
// empty pane says is the tool's own intro; the tab's mark is the shell's, from what the tool says here.
|
|
325
266
|
const bare = pages.length === 0 && !entry?.notice;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
grip.hidden = bare && shut;
|
|
329
|
-
|
|
330
|
-
applyGeometry(shut);
|
|
331
|
-
spineBadge.textContent = unread > 0 ? String(unread) : "";
|
|
332
|
-
spineBadge.hidden = unread === 0;
|
|
267
|
+
// A page that arrived is a thing to look at, not a thing to answer, so the tab says so without a number.
|
|
268
|
+
noteTool(PAGES, { unread, empty: bare, counted: false });
|
|
333
269
|
|
|
334
270
|
chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
|
|
335
271
|
const at = pages.findIndex((page) => page.id === entry?.selected);
|
|
@@ -359,80 +295,28 @@ function render() {
|
|
|
359
295
|
pageDoc.append(emptyState(notice));
|
|
360
296
|
return;
|
|
361
297
|
}
|
|
298
|
+
// Fetching a page is what marks it read - the server owns that, and it answers with a fresh list. So a
|
|
299
|
+
// tool nobody is looking at must not fetch: the tab's own mark, set a few lines up, would be cleared by
|
|
300
|
+
// the frame that fetch provokes, before anyone had seen it. The document is drawn when the user opens
|
|
301
|
+
// the tool, by this same function.
|
|
302
|
+
if (!isToolOpen(PAGES)) return;
|
|
362
303
|
show(sid, entry.selected);
|
|
363
304
|
// A chip that scrolled out of the bar while the user was reading something else.
|
|
364
305
|
chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
365
306
|
}
|
|
366
307
|
|
|
308
|
+
// The tool draws itself whenever the pane asks - a tab click, a width that changed, a session switch.
|
|
309
|
+
registerTool(PAGES, render);
|
|
310
|
+
|
|
367
311
|
setInterval(() => {
|
|
368
|
-
if (!
|
|
312
|
+
if (!isPaneCollapsed() && isToolOpen(PAGES)) render();
|
|
369
313
|
}, AGE_TICK_MS);
|
|
370
314
|
|
|
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
315
|
// A bar that fits at one width may not at another, and the arrows only exist when it does not.
|
|
436
316
|
window.addEventListener("resize", render);
|
|
317
|
+
// The bar hides its scrollbar, so these are the only way to reach a chip that scrolled out of it. A little
|
|
318
|
+
// under a full bar each time, so the chip at the edge stays on the screen and says where the bar now is.
|
|
319
|
+
panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
320
|
+
paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
437
321
|
// The pagebar is chrome: a click on it must not leave the terminal without focus for no reason.
|
|
438
322
|
pagebar.addEventListener("mousedown", (event) => event.preventDefault());
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// reviews-host.js - the Code Reviews tool on the page.
|
|
2
|
+
//
|
|
3
|
+
// As the Specs host beside it: the tool's own name, its panel and its opening sentence, and the frame around
|
|
4
|
+
// them is `tool-host.js`.
|
|
5
|
+
|
|
6
|
+
import { toolReviews } from "./dom.js";
|
|
7
|
+
import { REVIEWS } from "./pane-shell.js";
|
|
8
|
+
import { createToolHost } from "./tool-host.js";
|
|
9
|
+
|
|
10
|
+
createToolHost({
|
|
11
|
+
id: REVIEWS,
|
|
12
|
+
label: "Code Reviews",
|
|
13
|
+
panel: toolReviews,
|
|
14
|
+
opening: "Opening the reviews in this repository.",
|
|
15
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// specs-host.js - the Specs tool on the page.
|
|
2
|
+
//
|
|
3
|
+
// The host around a tool's frame is `tool-host.js`, where every tool's is. What is here is which tool this
|
|
4
|
+
// is: its name, which is what its routes and its messages are named after, the panel the pane gave it, and
|
|
5
|
+
// the sentence its tab shows while it is opening.
|
|
6
|
+
|
|
7
|
+
import { toolSpecs } from "./dom.js";
|
|
8
|
+
import { SPECS } from "./pane-shell.js";
|
|
9
|
+
import { createToolHost } from "./tool-host.js";
|
|
10
|
+
|
|
11
|
+
createToolHost({
|
|
12
|
+
id: SPECS,
|
|
13
|
+
label: "Specs",
|
|
14
|
+
panel: toolSpecs,
|
|
15
|
+
opening: "Opening the specs in this repository.",
|
|
16
|
+
});
|
|
@@ -13,6 +13,10 @@ export let maxSessions = 8;
|
|
|
13
13
|
export let defaultAgent = "the agent";
|
|
14
14
|
export let agents = [];
|
|
15
15
|
export let workspaceName = "";
|
|
16
|
+
// The accent colour id the user picked for this workspace, or "" for none. What is stored, never what is on
|
|
17
|
+
// the screen: the panel marks this, so "nothing picked" stays tellable from "picked the colour the port
|
|
18
|
+
// would have given anyway".
|
|
19
|
+
export let workspaceColorId = "";
|
|
16
20
|
// Where "+ New session" starts an agent, relative to the workspace root ("" is the root itself). The server
|
|
17
21
|
// owns it - it follows the directory chamba was last run in - and the picker opens on it.
|
|
18
22
|
export let defaultCwd = "";
|
|
@@ -26,6 +30,7 @@ export function adoptSessionsFrame(msg) {
|
|
|
26
30
|
defaultAgent = msg.agent || defaultAgent;
|
|
27
31
|
if (Array.isArray(msg.agents)) agents = msg.agents;
|
|
28
32
|
workspaceName = msg.workspace || "";
|
|
33
|
+
workspaceColorId = typeof msg.color === "string" ? msg.color : "";
|
|
29
34
|
defaultCwd = msg.defaultCwd ?? "";
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -18,6 +18,7 @@ import { repositionNewPop, toggleNewPop } from "./new-session.js";
|
|
|
18
18
|
import { agents, attachedSid, defaultAgent, defaultCwd, maxSessions, sessions, workspaceName } from "./state.js";
|
|
19
19
|
import { focusTerminal, term } from "./terminal.js";
|
|
20
20
|
import { colorFor } from "./theme.js";
|
|
21
|
+
import { repositionColorPop, workspaceColorButton } from "./workspace-color.js";
|
|
21
22
|
|
|
22
23
|
// The session tab being renamed right now (its id), and the text in its editor. Kept in module state so a
|
|
23
24
|
// bar re-render (an incoming server frame, or the 60s age tick) rebuilds the editor without losing what
|
|
@@ -513,11 +514,14 @@ export function renderBar() {
|
|
|
513
514
|
ws.textContent = workspaceName;
|
|
514
515
|
right.append(ws);
|
|
515
516
|
}
|
|
516
|
-
|
|
517
|
+
// The colour first: it is about this window's identity rather than about what the window does, so it sits
|
|
518
|
+
// next to the name it belongs with, on the far side of the bell from the button that ends everything.
|
|
519
|
+
right.append(workspaceColorButton(), bellButton(), stopButton());
|
|
517
520
|
tabbar.append(right);
|
|
518
521
|
|
|
519
|
-
//
|
|
522
|
+
// Both panels hang off the body, so they survive this rebuild - but the buttons under them just moved.
|
|
520
523
|
repositionNewPop();
|
|
524
|
+
repositionColorPop();
|
|
521
525
|
|
|
522
526
|
// Keep focus in the rename editor across re-renders. The first render after a double-click selects the
|
|
523
527
|
// whole label so it can be typed over; a later render (an incoming frame) only restores the caret.
|
|
@@ -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();
|
|
@@ -1,25 +1,23 @@
|
|
|
1
|
-
// theme.js -
|
|
1
|
+
// theme.js - what colour this workspace is wearing right now, and what colour its sessions get.
|
|
2
2
|
//
|
|
3
|
-
// The workspace
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// so two containers open side by side are almost never
|
|
3
|
+
// The workspace wears one colour: the composer border, the active tab's edge, the buttons and the frame around
|
|
4
|
+
// the tab icon are all that one colour, so a window is recognisable across a screen full of them before a
|
|
5
|
+
// single word is read. The user picks it from the bar, and until someone does it comes from the published
|
|
6
|
+
// port, which chamba assigns per workspace, so two containers open side by side are almost never alike.
|
|
7
7
|
//
|
|
8
8
|
// The sessions inside that window then rotate through the slots the workspace did not take. That is what
|
|
9
9
|
// makes a tab's own dot mean "this session" rather than "this workspace", and it is why a dot can never come
|
|
10
10
|
// out the same colour as the window it lives in.
|
|
11
|
+
//
|
|
12
|
+
// The colour moves while the page is open, so nothing here is a constant for anyone else to import: it is read
|
|
13
|
+
// through workspaceColor() and set through applyWorkspaceColor(). A module that took a copy at load would keep
|
|
14
|
+
// drawing the colour the workspace had when it started. The palette and the rules over it are in palette.js,
|
|
15
|
+
// which touches no globals and is where the tests check them.
|
|
16
|
+
//
|
|
17
|
+
// The stored colour lives in the container and arrives over the socket, which connects after the first paint.
|
|
18
|
+
// So this module also keeps the browser's own copy of the last colour it was told, and paints that first.
|
|
11
19
|
|
|
12
|
-
|
|
13
|
-
// those read as a warning here, and the bar has enough to say without one. Keep the length one longer than
|
|
14
|
-
// PALETTE_SIZE in sessions.js (a test pins it): the workspace eats one slot, sessions rotate the rest.
|
|
15
|
-
const PALETTE = [
|
|
16
|
-
"#2fe58a", // spring green
|
|
17
|
-
"#1fe0cf", // turquoise
|
|
18
|
-
"#f24bff", // magenta
|
|
19
|
-
"#b45cff", // neon violet
|
|
20
|
-
"#29b6ff", // azure
|
|
21
|
-
"#ff2e88", // rose
|
|
22
|
-
];
|
|
20
|
+
import { automaticId, hexFor, sessionColors } from "./palette.js";
|
|
23
21
|
|
|
24
22
|
// A stable number for this window when there is no port to read (a proxy, or the default 80/443). Not a
|
|
25
23
|
// hash worth defending - it only has to be the same on every reload of the same address.
|
|
@@ -29,18 +27,68 @@ function hostSeed() {
|
|
|
29
27
|
return seed;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
30
|
+
// What this workspace wears when nothing is stored for it.
|
|
31
|
+
const AUTOMATIC_ID = automaticId(Number(location.port) || hostSeed());
|
|
32
|
+
|
|
33
|
+
// The last colour this browser was told, kept so the next load paints it before the socket answers. One
|
|
34
|
+
// localStorage entry, named the way the interface names its own (webterm-sound, webterm-pane).
|
|
35
|
+
//
|
|
36
|
+
// A cache and never the truth. The file in the workspace's state directory decides, and the first sessions
|
|
37
|
+
// frame replaces whatever is held here. It exists for one thing: without it every load would open on the
|
|
38
|
+
// colour the port gives and then jump to the stored one, which is the flicker this work was asked to settle.
|
|
39
|
+
// localStorage is held per origin and the origin names the port, so a workspace whose published port moves
|
|
40
|
+
// starts again with nothing cached - it paints the new automatic colour once, then settles on the stored one.
|
|
41
|
+
const COLOR_KEY = "webterm-color";
|
|
42
|
+
|
|
43
|
+
function cachedColorId() {
|
|
44
|
+
try {
|
|
45
|
+
return localStorage.getItem(COLOR_KEY) || "";
|
|
46
|
+
} catch {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Written whenever a frame carries a colour, and removed when one carries "". A browser that refuses storage
|
|
52
|
+
// still works: it just opens on the automatic colour every time and corrects itself when the socket answers.
|
|
53
|
+
//
|
|
54
|
+
// Only on a change. A sessions frame goes out on every working-or-idle transition the registry notices, which
|
|
55
|
+
// is many a minute under a busy agent, and this is a synchronous write of a value that almost never moves.
|
|
56
|
+
function rememberColorId(id) {
|
|
57
|
+
try {
|
|
58
|
+
if (id === cachedColorId()) return;
|
|
59
|
+
if (id === "") localStorage.removeItem(COLOR_KEY);
|
|
60
|
+
else localStorage.setItem(COLOR_KEY, id);
|
|
61
|
+
} catch {
|
|
62
|
+
// Nothing to do about it, and nothing worth saying to the user.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
39
65
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
let workspaceHex = "";
|
|
67
|
+
let sessionHexes = [];
|
|
68
|
+
|
|
69
|
+
// Handed to the stylesheet: everything that carries the window's identity reads --ws from there, so one line
|
|
70
|
+
// in the page decides what colour this workspace is. Called once below, before anything is drawn, and again
|
|
71
|
+
// whenever the stored colour changes.
|
|
72
|
+
export function applyWorkspaceColor(id) {
|
|
73
|
+
// Cached here rather than in the frame router, because the two callers are the frame that carries a
|
|
74
|
+
// colour and the load below that reads the cache back - so caching what is applied is caching what the
|
|
75
|
+
// server last said, and the load's own call rewrites the value it just read.
|
|
76
|
+
rememberColorId(id);
|
|
77
|
+
// The empty string asks for automatic, and an id this version does not know means the same thing.
|
|
78
|
+
const chosen = hexFor(id) ? id : AUTOMATIC_ID;
|
|
79
|
+
workspaceHex = hexFor(chosen);
|
|
80
|
+
sessionHexes = sessionColors(chosen, AUTOMATIC_ID);
|
|
81
|
+
document.documentElement.style.setProperty("--ws", workspaceHex);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The colour the workspace is wearing, read fresh by everything that draws it. */
|
|
85
|
+
export function workspaceColor() {
|
|
86
|
+
return workspaceHex;
|
|
87
|
+
}
|
|
43
88
|
|
|
44
89
|
export function colorFor(entry) {
|
|
45
|
-
return
|
|
90
|
+
return sessionHexes[(entry.colorIndex ?? 0) % sessionHexes.length];
|
|
46
91
|
}
|
|
92
|
+
|
|
93
|
+
// Before anything is drawn, and before the socket has answered: what this browser was told last time.
|
|
94
|
+
applyWorkspaceColor(cachedColorId());
|