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.
Files changed (118) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +38 -11
  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 -184
  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 -260
  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 -26
  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 +6 -6
  28. package/templates/Dockerfile +24 -12
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +2 -0
  31. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  32. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  33. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  34. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  35. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  36. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  37. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
  38. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
  39. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  40. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  41. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  42. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  43. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  44. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  45. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  46. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  47. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  48. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  49. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  50. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  51. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  52. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  53. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
  54. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
  55. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  56. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  57. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  58. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  59. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
  60. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  61. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  62. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  63. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  64. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  65. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  66. package/templates/pane-apps/client/reviews/index.html +15 -0
  67. package/templates/pane-apps/client/specs/index.html +14 -0
  68. package/templates/pane-apps/server/reviews.mjs +20 -0
  69. package/templates/pane-apps/server/specs.mjs +5 -0
  70. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  71. package/templates/skills/dx-review/SKILL.md +123 -0
  72. package/templates/skills/dx-review/references/acts.md +162 -0
  73. package/templates/skills/dx-spec/SKILL.md +561 -0
  74. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  75. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  76. package/templates/skills/dx-spec/references/review-guide.md +127 -0
  77. package/templates/skills/dx-spec/references/spec-guide.md +145 -0
  78. package/templates/skills/dx-spec/references/stages.md +484 -0
  79. package/templates/skills/dx-spec-execute/SKILL.md +437 -0
  80. package/templates/startup.mjs +10 -4
  81. package/templates/tool-helper.sh +166 -0
  82. package/templates/webterm/README.md +79 -9
  83. package/templates/webterm/color.js +61 -0
  84. package/templates/webterm/config.js +74 -0
  85. package/templates/webterm/context/claude.md +0 -1
  86. package/templates/webterm/public/app/alerts.js +4 -4
  87. package/templates/webterm/public/app/anchored.js +81 -0
  88. package/templates/webterm/public/app/composer.js +4 -1
  89. package/templates/webterm/public/app/connection.js +9 -0
  90. package/templates/webterm/public/app/dom.js +15 -5
  91. package/templates/webterm/public/app/frames.js +51 -10
  92. package/templates/webterm/public/app/main.js +26 -6
  93. package/templates/webterm/public/app/new-session.js +13 -41
  94. package/templates/webterm/public/app/palette.js +58 -0
  95. package/templates/webterm/public/app/pane-shape.js +167 -0
  96. package/templates/webterm/public/app/pane-shell.js +380 -0
  97. package/templates/webterm/public/app/pane.js +77 -193
  98. package/templates/webterm/public/app/reviews-host.js +15 -0
  99. package/templates/webterm/public/app/specs-host.js +16 -0
  100. package/templates/webterm/public/app/state.js +5 -0
  101. package/templates/webterm/public/app/tabs.js +6 -2
  102. package/templates/webterm/public/app/terminal.js +8 -0
  103. package/templates/webterm/public/app/theme.js +75 -27
  104. package/templates/webterm/public/app/tool-host.js +302 -0
  105. package/templates/webterm/public/app/workspace-color.js +192 -0
  106. package/templates/webterm/public/index.html +68 -30
  107. package/templates/webterm/public/styles.css +270 -34
  108. package/templates/webterm/server.js +321 -0
  109. package/templates/webterm/tool-document.js +67 -0
  110. package/templates/webterm/tools/commands.js +61 -0
  111. package/templates/webterm/tools/index.js +25 -0
  112. package/templates/webterm/tools/mount.js +337 -0
  113. package/templates/webterm/tools/paths.js +34 -0
  114. package/templates/webterm/tools/reviews.js +133 -0
  115. package/templates/webterm/tools/specs.js +154 -0
  116. package/templates/webterm/typed-line.js +148 -0
  117. package/templates/context/context-usage.md +0 -1
  118. package/templates/context-usage.sh +0 -266
@@ -1,58 +1,46 @@
1
- // pane.js - the web pane: the pages an agent published, beside the terminal it published them from.
1
+ // pane.js - the Pages tool: the pages an agent published, beside the terminal it published them from.
2
2
  //
3
- // The pane is chrome this interface draws - the chips bar, the counter, the spine - around a page it does
4
- // not trust (pane-frame.js renders that part). Keeping the two apart is the point: a published page can
5
- // never fake which session and which page the user is looking at, because everything that says so is drawn
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
- // What the pane shows belongs to the attached session, and it follows the tabs: switching sessions switches
9
- // the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is, whether it
10
- // is collapsed, and how big a page is drawn in it - and that survives the switch, and a reload.
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 pane exists before any of that. A session that has published nothing still has its spine, and opening it
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
- let width = null;
70
- // null means nobody has said. An untouched pane keeps out of the way while its session has nothing in it, and
71
- // is open once it has - which is the first page of a session opening on arrival, as it always did. A click on
72
- // the spine or on the collapse button is an answer, and from then on it is the only one that counts.
73
- let collapsed = null;
74
- // Which of TEXT_SIZES the shown page is drawn at.
75
- let sizeStep = DEFAULT_SIZE_STEP;
76
- // What the last render decided, for the two things that only need to know whether the pane is showing.
77
- let shut = true;
78
-
79
- // --- What the window remembers -----------------------------------------------------------------------------------------------------------
80
-
81
- try {
82
- const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
83
- if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
84
- if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
85
- // The multiplier is stored rather than its position, so a window that was left at a size this version no
86
- // longer offers falls back to the default instead of landing between two steps.
87
- const step = TEXT_SIZES.indexOf(stored.size);
88
- if (step !== -1) sizeStep = step;
89
- } catch {
90
- // A disabled or full store only means the pane opens at its default width.
91
- }
92
-
93
- function rememberGeometry() {
94
- try {
95
- localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, size: TEXT_SIZES[sizeStep] }));
96
- } catch {
97
- // The pane still works for this page's lifetime.
98
- }
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 pane's own state ----------------------------------------------------------------------------------------------------------------
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
- flashArrival();
94
+ if (isToolOpen(PAGES)) flashPane();
128
95
  }
129
- // A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
130
- // has said, already opens the pane when its session stops being empty, and settling it here would open
131
- // the pane in every other empty session in this window too.
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 pane for whatever session this window is now on, asking for its pages when this window has never
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 = "The web pane";
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 the pane, and the stylesheet zooms the frame from it. Two reasons, both structural:
273
- // the frame's document has an opaque origin and the shell cannot reach into it, and renderPage() throws the
274
- // iframe away and builds a fresh one on every chip click, so anything set on the frame itself would have to be
275
- // put back on every one of those paths. Setting it out here costs nothing and cannot be missed.
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 size = TEXT_SIZES[sizeStep];
278
- pane.style.setProperty("--page-zoom", String(size));
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 = sizeStep === 0;
282
- pageBigger.disabled = sizeStep === TEXT_SIZES.length - 1;
239
+ pageSmaller.disabled = step === 0;
240
+ pageBigger.disabled = step === TEXT_SIZES.length - 1;
283
241
  }
284
242
 
285
243
  function stepTextSize(by) {
286
- const next = Math.min(Math.max(sizeStep + by, 0), TEXT_SIZES.length - 1);
287
- if (next === sizeStep) return;
288
- sizeStep = next;
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 pane stays - as the spine, until
323
- // someone opens it - because a pane that only appears once an agent has used it is one nobody knows to
324
- // ask for. The grip is the part that goes: there is no width worth choosing for a page that does not exist.
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
- shut = collapsed === null ? bare : collapsed;
327
- pane.hidden = false;
328
- grip.hidden = bare && shut;
329
-
330
- applyGeometry(shut);
331
- spineBadge.textContent = unread > 0 ? String(unread) : "";
332
- spineBadge.hidden = unread === 0;
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 (!shut) render();
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
- 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.
@@ -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 - 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());