chamba 0.7.0 → 0.9.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.
Files changed (134) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +34 -8
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -210
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -261
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -36
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +4 -4
  28. package/templates/Dockerfile +15 -22
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +4 -2
  31. package/templates/context/web-pane-craft.md +1 -1
  32. package/templates/pane-apps/client/assets/api-BBDf_rzz.js +12 -0
  33. package/templates/pane-apps/client/assets/api-DNLbSBpK.css +1 -0
  34. package/templates/pane-apps/client/assets/bee-B7t97l5B.webp +0 -0
  35. package/templates/pane-apps/client/assets/buzz-CUUsbnXH.mp3 +0 -0
  36. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  37. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  38. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  39. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  40. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  41. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  42. package/templates/pane-apps/client/assets/files-BkmyervY.js +1 -0
  43. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  44. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  45. package/templates/pane-apps/client/assets/home-3SeGzySM.css +1 -0
  46. package/templates/pane-apps/client/assets/home-Cu9xOd2i.js +1 -0
  47. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  48. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  49. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  50. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  51. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  52. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  53. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  54. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  55. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  56. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  57. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  58. package/templates/pane-apps/client/assets/pop-D5ZANBC3.mp3 +0 -0
  59. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  60. package/templates/pane-apps/client/assets/reviews-DMrGB-kG.js +153 -0
  61. package/templates/pane-apps/client/assets/reviews-nK_yRav9.css +1 -0
  62. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  63. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  64. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  65. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  66. package/templates/pane-apps/client/assets/specs-Cabb15F0.js +13 -0
  67. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  68. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  69. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  70. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  71. package/templates/pane-apps/client/assets/useNow-BHFKFraS.js +1 -0
  72. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  73. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  74. package/templates/pane-apps/client/home/index.html +16 -0
  75. package/templates/pane-apps/client/reviews/index.html +17 -0
  76. package/templates/pane-apps/client/specs/index.html +4 -2
  77. package/templates/pane-apps/server/home.mjs +12 -0
  78. package/templates/pane-apps/server/reviews.mjs +21 -0
  79. package/templates/pane-apps/server/specs.mjs +5 -1588
  80. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  81. package/templates/skills/dx-review/SKILL.md +136 -0
  82. package/templates/skills/dx-review/references/acts.md +233 -0
  83. package/templates/skills/dx-spec/SKILL.md +230 -30
  84. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  85. package/templates/skills/dx-spec/references/review-guide.md +6 -52
  86. package/templates/skills/dx-spec/references/spec-guide.md +25 -5
  87. package/templates/skills/dx-spec/references/stages.md +149 -60
  88. package/templates/skills/dx-spec-execute/SKILL.md +217 -38
  89. package/templates/startup.mjs +22 -16
  90. package/templates/tool-helper.sh +168 -0
  91. package/templates/webterm/README.md +82 -27
  92. package/templates/webterm/color.js +61 -0
  93. package/templates/webterm/config.js +55 -4
  94. package/templates/webterm/context/claude.md +0 -1
  95. package/templates/webterm/public/app/alerts.js +95 -14
  96. package/templates/webterm/public/app/anchored.js +81 -0
  97. package/templates/webterm/public/app/composer.js +37 -3
  98. package/templates/webterm/public/app/connection.js +23 -0
  99. package/templates/webterm/public/app/dom.js +3 -0
  100. package/templates/webterm/public/app/frames.js +52 -18
  101. package/templates/webterm/public/app/home-host.js +16 -0
  102. package/templates/webterm/public/app/main.js +24 -8
  103. package/templates/webterm/public/app/new-session.js +13 -41
  104. package/templates/webterm/public/app/palette.js +58 -0
  105. package/templates/webterm/public/app/pane-shape.js +169 -0
  106. package/templates/webterm/public/app/pane-shell.js +240 -135
  107. package/templates/webterm/public/app/pane.js +39 -24
  108. package/templates/webterm/public/app/reviews-host.js +19 -0
  109. package/templates/webterm/public/app/specs-host.js +12 -218
  110. package/templates/webterm/public/app/state.js +5 -0
  111. package/templates/webterm/public/app/tabs.js +6 -2
  112. package/templates/webterm/public/app/theme.js +75 -27
  113. package/templates/webterm/public/app/tool-host.js +318 -0
  114. package/templates/webterm/public/app/workspace-color.js +192 -0
  115. package/templates/webterm/public/index.html +22 -4
  116. package/templates/webterm/public/styles.css +195 -21
  117. package/templates/webterm/server.js +198 -150
  118. package/templates/webterm/tool-document.js +3 -2
  119. package/templates/webterm/tools/commands.js +61 -0
  120. package/templates/webterm/tools/home.js +58 -0
  121. package/templates/webterm/tools/index.js +26 -0
  122. package/templates/webterm/tools/mount.js +337 -0
  123. package/templates/webterm/tools/paths.js +34 -0
  124. package/templates/webterm/tools/reviews.js +137 -0
  125. package/templates/webterm/tools/specs.js +154 -0
  126. package/templates/webterm/typed-line.js +70 -7
  127. package/templates/context/context-usage.md +0 -1
  128. package/templates/context-usage.sh +0 -266
  129. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  130. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  131. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  132. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  133. package/templates/specs.sh +0 -106
  134. package/templates/webterm/specs.js +0 -358
@@ -31,10 +31,10 @@ import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js
31
31
  import {
32
32
  flashPane,
33
33
  isPaneCollapsed,
34
- isPaneShut,
35
34
  isToolOpen,
36
35
  noteTool,
37
36
  PAGES,
37
+ pulseTool,
38
38
  registerTool,
39
39
  rememberPageSize,
40
40
  setPaneCollapsed,
@@ -55,11 +55,14 @@ const AGE_TICK_MS = 30_000;
55
55
  // heard about, so switching back to a tab lands on the page it was left on.
56
56
  const panes = new Map();
57
57
 
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;
58
+ // Which of TEXT_SIZES the shown page is drawn at. It belongs to the session, so it is asked of the pane each
59
+ // time rather than held here: a switch changes the answer. The multiplier is what the pane stores, so a
60
+ // session left at a size this version no longer offers falls back to the default instead of landing between
61
+ // two steps.
62
+ function sizeStep() {
63
+ const at = TEXT_SIZES.indexOf(storedPageSize());
64
+ return at === -1 ? DEFAULT_SIZE_STEP : at;
65
+ }
63
66
 
64
67
  // --- The tool's own state ----------------------------------------------------------------------------------------------------------------
65
68
 
@@ -89,11 +92,13 @@ export function applyPages(msg) {
89
92
  // saying-so is held back while another tool has the pane.
90
93
  if (arrived !== null) {
91
94
  select(msg.sid, arrived.id, { user: false });
95
+ // The pane switches tools for nobody. With Pages open the pane's own edge says the page is here;
96
+ // with another tool open the Pages tab says it, and the tool the user is working in is left alone.
92
97
  if (isToolOpen(PAGES)) flashPane();
98
+ else pulseTool(PAGES);
93
99
  }
94
- // A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
95
- // has said, already opens the pane when its session stops being empty, and settling it here would open
96
- // the pane in every other empty session in this window too.
100
+ // A pane someone put away comes back for a page, in the session the page was published in - which is
101
+ // the session this frame is about, since the list only reaches the tool for the one it is driving.
97
102
  if (arrived !== null && isToolOpen(PAGES) && isPaneCollapsed()) setPaneCollapsed(false);
98
103
  else render();
99
104
  }
@@ -172,7 +177,7 @@ function introState() {
172
177
  const wrap = document.createElement("div");
173
178
  wrap.className = "pane-intro";
174
179
  const title = document.createElement("h2");
175
- title.textContent = "The web pane";
180
+ title.textContent = "Pages";
176
181
  const lede = document.createElement("p");
177
182
  lede.textContent = "Ask the agent for a page whenever the answer is longer or more structured than the terminal carries well:";
178
183
  const asks = document.createElement("ul");
@@ -230,38 +235,43 @@ function chipFor(sid, page, selected) {
230
235
  // would have to be put back on every one of those paths. Setting it out here costs nothing and cannot be
231
236
  // missed.
232
237
  function applyTextSize() {
233
- const size = TEXT_SIZES[sizeStep];
238
+ const step = sizeStep();
239
+ const size = TEXT_SIZES[step];
234
240
  toolPages.style.setProperty("--page-zoom", String(size));
235
241
  pageSizeVal.textContent = `${Math.round(size * 100)}%`;
236
242
  // A control that can do nothing says so, rather than swallowing the press.
237
- pageSmaller.disabled = sizeStep === 0;
238
- pageBigger.disabled = sizeStep === TEXT_SIZES.length - 1;
243
+ pageSmaller.disabled = step === 0;
244
+ pageBigger.disabled = step === TEXT_SIZES.length - 1;
239
245
  }
240
246
 
241
247
  function stepTextSize(by) {
242
- const next = Math.min(Math.max(sizeStep + by, 0), TEXT_SIZES.length - 1);
243
- if (next === sizeStep) return;
244
- sizeStep = next;
248
+ const step = sizeStep();
249
+ const next = Math.min(Math.max(step + by, 0), TEXT_SIZES.length - 1);
250
+ if (next === step) return;
251
+ rememberPageSize(TEXT_SIZES[next]);
245
252
  applyTextSize();
246
- rememberPageSize(TEXT_SIZES[sizeStep]);
247
253
  }
248
254
 
249
255
  pageSmaller.addEventListener("click", () => stepTextSize(-1));
250
256
  pageBigger.addEventListener("click", () => stepTextSize(1));
251
- applyTextSize();
252
257
 
253
258
  function render() {
259
+ // The zoom is the session's, so it is applied wherever this tool draws rather than once at load: the
260
+ // first draw is already in a session's shape, and every switch draws again.
261
+ applyTextSize();
254
262
  const sid = attachedSid;
255
263
  const entry = sid === null ? null : (panes.get(sid) ?? null);
256
264
  const pages = entry?.pages ?? [];
257
265
  const unread = pages.filter((page) => page.unread).length;
258
266
 
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.
267
+ // Nothing published in this session, and nothing to say about it. The tab stays and the pane stays open,
268
+ // because a pane that only appears once an agent has used it is one nobody knows to ask for. What the
269
+ // empty pane says is the tool's own intro; the tab's mark is the shell's, from what the tool says here.
263
270
  const bare = pages.length === 0 && !entry?.notice;
264
- noteTool(PAGES, { unread, empty: bare });
271
+ // How many pages nobody has opened, the way Specs counts its open asks. Three waiting is a different day
272
+ // from one, and the pages already read are not a number anyone acts on, so the tab counts the unopened
273
+ // ones alone and never the whole list.
274
+ noteTool(PAGES, { unread, empty: bare, counted: true });
265
275
 
266
276
  chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
267
277
  const at = pages.findIndex((page) => page.id === entry?.selected);
@@ -291,6 +301,11 @@ function render() {
291
301
  pageDoc.append(emptyState(notice));
292
302
  return;
293
303
  }
304
+ // Fetching a page is what marks it read - the server owns that, and it answers with a fresh list. So a
305
+ // tool nobody is looking at must not fetch: the tab's own mark, set a few lines up, would be cleared by
306
+ // the frame that fetch provokes, before anyone had seen it. The document is drawn when the user opens
307
+ // the tool, by this same function.
308
+ if (!isToolOpen(PAGES)) return;
294
309
  show(sid, entry.selected);
295
310
  // A chip that scrolled out of the bar while the user was reading something else.
296
311
  chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
@@ -300,7 +315,7 @@ function render() {
300
315
  registerTool(PAGES, render);
301
316
 
302
317
  setInterval(() => {
303
- if (!isPaneShut() && isToolOpen(PAGES)) render();
318
+ if (!isPaneCollapsed() && isToolOpen(PAGES)) render();
304
319
  }, AGE_TICK_MS);
305
320
 
306
321
  // A bar that fits at one width may not at another, and the arrows only exist when it does not.
@@ -0,0 +1,19 @@
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
+ // "Reviews" and not "Code Reviews", because this word is what the user reads on the tab and in the panel
7
+ // while the tool opens, and four tabs have to fit at the width the pane reopens at. The tool's own name is
8
+ // Code Reviews everywhere else, the server table included, so every refusal still names it in full.
9
+
10
+ import { toolReviews } from "./dom.js";
11
+ import { REVIEWS } from "./pane-shell.js";
12
+ import { createToolHost } from "./tool-host.js";
13
+
14
+ createToolHost({
15
+ id: REVIEWS,
16
+ label: "Reviews",
17
+ panel: toolReviews,
18
+ opening: "Opening the reviews in this repository.",
19
+ });
@@ -1,222 +1,16 @@
1
- // specs-host.js - the Specs tool: the spec workspace, in a frame of its own.
1
+ // specs-host.js - the Specs tool on the page.
2
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.
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.
13
6
 
14
- import { sendFrame } from "./connection.js";
15
7
  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 });
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.",
189
16
  });
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();
@@ -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
- right.append(bellButton(), stopButton());
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
- // The panel hangs off the body, so it survives this rebuild - but the button under it just moved.
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.
@@ -1,25 +1,23 @@
1
- // theme.js - one palette, used for two different jobs.
1
+ // theme.js - what colour this workspace is wearing right now, and what colour its sessions get.
2
2
  //
3
- // The workspace takes one slot of it and keeps it: the composer border, the active tab's edge, the buttons
4
- // and the frame around the tab icon are all that one colour, so a window is recognisable across a screen full
5
- // of them before a single word is read. The slot comes from the published port, which chamba assigns per workspace,
6
- // so two containers open side by side are almost never the same colour and nothing has to be configured.
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
- // Cyberpunk neons on a near-black bar, spaced around the wheel and deliberately free of yellow and orange -
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
- // The workspace's slot in the palette. Ports are handed out lowest-free-first per workspace, so neighbours
33
- // land on different colours.
34
- const WORKSPACE_SLOT = (Number(location.port) || hostSeed()) % PALETTE.length;
35
- export const WORKSPACE_COLOR = PALETTE[WORKSPACE_SLOT];
36
- // Every slot except the workspace's own, in palette order. A session index maps into this, so "never the
37
- // workspace colour" is a property of the list rather than a rule someone has to remember.
38
- const SESSION_COLORS = PALETTE.filter((_, slot) => slot !== WORKSPACE_SLOT);
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
- // Handed to the stylesheet once, before anything is drawn: everything that carries the window's identity
41
- // reads it from there, so there is one line in the page that decides what colour this workspace is.
42
- document.documentElement.style.setProperty("--ws", WORKSPACE_COLOR);
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 SESSION_COLORS[(entry.colorIndex ?? 0) % SESSION_COLORS.length];
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());