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
@@ -1,9 +1,10 @@
1
1
  // pane-shell.js - the pane itself: which tool is open in it, and how wide it is.
2
2
  //
3
- // The pane is furniture. What sits in it are tools - Pages (pane.js), Specs (specs-host.js) - and the line
4
- // between them is the whole point of this module: a tool draws inside its own panel and nothing else, while
5
- // the tab bar that says which tool is open, and the controls that say how wide the pane is, are drawn out
6
- // here. A tool can therefore never move the pane, and never lie about which tool the user is looking at.
3
+ // The pane is furniture. What sits in it are tools - Home (home-host.js), Specs (specs-host.js), Code Reviews
4
+ // (reviews-host.js) and Pages (pane.js) - and the line between them is the whole point of this module: a tool draws inside its
5
+ // own panel and nothing else, while the tab bar that says which tool is open, and the controls that say how
6
+ // wide the pane is, are drawn out here. A tool can therefore never move the pane, and never lie about which
7
+ // tool the user is looking at.
7
8
  //
8
9
  // Width has three states, and only one of them is a width. Between the two ends the divider drags anything,
9
10
  // clamped as it always was. Past the near end the pane is put away and becomes the spine. Past the far end it
@@ -11,90 +12,158 @@
11
12
  // drag's own clamps stay where they are. The grip is reachable in every one of them, and dragging it is
12
13
  // always the way back.
13
14
  //
14
- // What the pane is belongs to the window - how wide, which tool, how big a page is drawn - and survives a
15
- // session switch and a reload. What is in a tool belongs to the session, and the tool keeps that itself.
15
+ // What the pane is belongs to the session it is beside - how wide, which tool, how big a page is drawn, and
16
+ // the reading theme - so switching sessions gives each one the pane it was left with, and a reload gives them
17
+ // all back. What is in a tool belongs to the session too, and the tool keeps that itself.
16
18
  //
17
- // This module imports nothing but dom.js, deliberately: the tools import it and register themselves as they
18
- // load, so it has to be finished before any of them runs.
19
-
20
- import { grip, pane, panebar, paneCollapse, paneFull, spine, spineBadge, toolPages, toolSpecs, toolTabs } from "./dom.js";
19
+ // This module imports dom.js and pane-shape.js and nothing else, deliberately: the tools import it and
20
+ // register themselves as they load, so it has to be finished before any of them runs. Both of those are
21
+ // leaves - pane-shape.js is the shape, its rules and its store, and it holds no element and reaches no
22
+ // other module. Every rule about a shape lives there rather than here, so it can be run in a test; what is
23
+ // left here is the pane as a thing on the screen.
24
+
25
+ import {
26
+ grip,
27
+ pane,
28
+ panebar,
29
+ paneCollapse,
30
+ paneFull,
31
+ paneHalf,
32
+ spine,
33
+ spineBadge,
34
+ toolHome,
35
+ toolPages,
36
+ toolReviews,
37
+ toolSpecs,
38
+ toolTabs,
39
+ } from "./dom.js";
40
+ import {
41
+ DEFAULT_FRACTION,
42
+ defaultShape,
43
+ draggedTo,
44
+ HOME,
45
+ isDefaultWidth,
46
+ mergeShape,
47
+ PAGES,
48
+ pruneShapes,
49
+ REVIEWS,
50
+ readShapes,
51
+ reopenedFrom,
52
+ SPECS,
53
+ } from "./pane-shape.js";
54
+
55
+ // The tools' ids come from the shape, which holds one of them. Re-exported here because this module is the
56
+ // pane, and a tool that asks whether it is open is asking the pane rather than the store.
57
+ export { HOME, PAGES, REVIEWS, SPECS };
21
58
 
22
59
  // The tools, in the order their tabs are drawn. A tool is a tab, a panel, and a render function it hands over
23
60
  // when it loads; the pane knows nothing else about any of them.
24
- export const PAGES = "pages";
25
- export const SPECS = "specs";
26
61
  const TOOLS = [
27
- { id: PAGES, label: "Pages", panel: toolPages },
62
+ { id: HOME, label: "Home", panel: toolHome },
28
63
  { id: SPECS, label: "Specs", panel: toolSpecs },
64
+ // "Reviews" on the tab and Code Reviews everywhere else: four tabs have to be readable at the width the
65
+ // pane reopens at, and the tab is the one place the tool's whole name does not fit.
66
+ { id: REVIEWS, label: "Reviews", panel: toolReviews },
67
+ { id: PAGES, label: "Pages", panel: toolPages },
29
68
  ];
30
69
 
31
- // Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
32
- // threshold, and the width the spine itself takes.
33
- const COLLAPSE_AT = 150;
34
- // What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
35
- const DEFAULT_FRACTION = 0.44;
36
- const MAX_FRACTION = 0.7;
37
- // The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
38
- // it was put away at, a pane you have just asked for has to be wide enough to read a page in.
39
- const MIN_OPEN_FRACTION = 1 / 3;
40
- // The window's own pane preferences: how wide, whether it is collapsed, whether it has the whole row, which
41
- // tool is open, and how big a page is drawn in it. Not the session's - a pane is furniture, and furniture
42
- // does not move because you looked at another tab.
43
- const GEOMETRY_KEY = "webterm-pane";
44
-
45
- let width = null;
46
- // null means nobody has said. An untouched pane keeps out of the way while the tool it is open on has nothing
47
- // in it, and is open once that tool has something - which is the first page of a session opening on arrival,
48
- // as it always did. A click on the spine or on the collapse button is an answer, and from then on it is the
49
- // only one that counts.
50
- let collapsed = null;
51
- // The far end: the pane has the whole row and the terminal is squeezed out.
52
- let full = false;
53
- let openTool = PAGES;
54
- // How big a page is drawn. The pane applies it, so it is stored here with the rest of the furniture, and the
55
- // Pages tool owns the control and the steps.
56
- let pageSize = null;
57
- // What the last render decided, for the two things that only need to know whether the pane is showing.
58
- let shut = true;
70
+ // The one shape this window is drawing, and the session it belongs to. One and not a map of them: the store
71
+ // is shared by every window of this origin, so a shape held here from a session this window left is a copy
72
+ // that has had time to go stale. It is read again on every move to another session instead.
73
+ let sid = null;
74
+ let current = defaultShape();
75
+ // Whether a frame from the server named that session. The id this window opens with comes from its own
76
+ // storage and is the session it is about to ask for, which is enough to draw in and not enough to write
77
+ // under: the server may hand this window a different one, or none.
78
+ let told = false;
59
79
 
60
80
  // id -> what that tool has to say for itself on its tab: how many things are waiting in it, and whether it
61
81
  // has anything at all. A tool nobody has heard from yet is empty and waiting on nothing.
62
- const EMPTY_MARK = { unread: 0, empty: true };
82
+ const EMPTY_MARK = { unread: 0, empty: true, counted: true };
63
83
  const marks = new Map();
64
84
  // id -> the tool's own render function, handed over as it loads.
65
85
  const renderers = new Map();
66
86
 
67
- // --- What the window remembers -----------------------------------------------------------------------------------------------------------
68
-
69
- try {
70
- const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
71
- if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
72
- if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
73
- if (typeof stored.full === "boolean") full = stored.full;
74
- if (TOOLS.some((tool) => tool.id === stored.tool)) openTool = stored.tool;
75
- // The multiplier is stored rather than its position, so a window that was left at a size this version no
76
- // longer offers falls back to the default instead of landing between two steps.
77
- if (typeof stored.size === "number") pageSize = stored.size;
78
- } catch {
79
- // A disabled or full store only means the pane opens at its default width, on its default tool.
80
- }
87
+ // --- What every session remembers --------------------------------------------------------------------------------------------------------
81
88
 
89
+ // Kept in the store, under the session this window is driving and no other. A window that has not been told
90
+ // its session yet writes nothing: the id it opened with may turn out to be another window's session, and the
91
+ // shape of that session is not this window's to set.
82
92
  function remember() {
83
- try {
84
- localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, full, tool: openTool, size: pageSize }));
85
- } catch {
86
- // The pane still works for this page's lifetime.
87
- }
93
+ if (!told) return;
94
+ mergeShape(localStorage, sid, current);
95
+ }
96
+
97
+ // The shape of one session, off the store. A window with no session draws the defaults and stores nothing,
98
+ // because there is nobody to store it under.
99
+ function shapeOf(next) {
100
+ if (next === null) return defaultShape();
101
+ return readShapes(localStorage).get(next) ?? defaultShape();
102
+ }
103
+
104
+ // Draw everything the shape decides: the pane's own chrome, and the tool open in it, which has a bar and a
105
+ // document to lay out at whatever width the pane now has.
106
+ function redraw() {
107
+ renderShell();
108
+ renderers.get(current.tool)?.();
109
+ }
110
+
111
+ // The shape just changed, so keep it under this session and draw it. Every act on the pane ends here.
112
+ function reshape() {
113
+ remember();
114
+ redraw();
88
115
  }
89
116
 
90
- /** How big a page was last drawn in this window, or null when nobody has said. */
117
+ /**
118
+ * The window is beside that session now, so the pane is drawn in that session's own shape.
119
+ *
120
+ * The shape is read again here rather than kept from the load, so a window coming back to a session sees
121
+ * what it was left at, whichever window left it there. Called before the tools are told, because what they
122
+ * lay out depends on how wide the pane now is and on which of them is open in it.
123
+ */
124
+ export function applySessionShape(next) {
125
+ const held = next ?? null;
126
+ // The first frame is worth acting on even where it names the session this window opened with: it is what
127
+ // makes the shape this window's to write, and the store may have moved since the page loaded.
128
+ if (held === sid && told) return;
129
+ // A drag in flight belongs to the session that is leaving. Ending it stores the width under that session
130
+ // rather than carrying the pointer into the next one's shape.
131
+ if (dragging) endDrag();
132
+ sid = held;
133
+ told = true;
134
+ current = shapeOf(held);
135
+ redraw();
136
+ }
137
+
138
+ /** A session that left the bar takes its pane shape with it, the way its unsent message and its page list do. */
139
+ export function prunePaneShapes(live) {
140
+ pruneShapes(localStorage, live);
141
+ }
142
+
143
+ /** How big a page was last drawn in this session, or null when nobody has said. */
91
144
  export function storedPageSize() {
92
- return pageSize;
145
+ return current.size;
93
146
  }
94
147
 
95
- /** The Pages tool's zoom, kept with the rest of the pane's furniture so one key has one writer. */
148
+ /** The Pages tool's zoom, kept with the rest of the session's shape so one key has one writer. */
96
149
  export function rememberPageSize(size) {
97
- pageSize = size;
150
+ current.size = size;
151
+ remember();
152
+ }
153
+
154
+ /** The reading theme this session was left on, or null for the system preference. */
155
+ export function storedPaneTheme() {
156
+ return current.theme;
157
+ }
158
+
159
+ /**
160
+ * The Specs tool's reading theme, kept with the rest of the session's shape.
161
+ *
162
+ * The tool's frame has an opaque origin and therefore no storage of its own, so the pane holds the choice
163
+ * and hands it back with the session. Anything but the two palettes means the system preference.
164
+ */
165
+ export function rememberPaneTheme(theme) {
166
+ current.theme = theme === "dark" || theme === "light" ? theme : null;
98
167
  remember();
99
168
  }
100
169
 
@@ -110,136 +179,155 @@ export function registerTool(id, render) {
110
179
 
111
180
  /** Whether the pane is open on that tool right now. */
112
181
  export function isToolOpen(id) {
113
- return openTool === id;
182
+ return current.tool === id;
114
183
  }
115
184
 
116
185
  /**
117
186
  * What a tool has to say on its tab: `unread` is what is waiting for the user in it, and `empty` is whether
118
187
  * it has anything to show at all - which is what an untouched pane reads to decide whether to stay out of
119
188
  * the way.
189
+ *
190
+ * `counted` is whether that number is worth showing. A tool that asks the user things counts them, because
191
+ * two questions waiting is a different day from one. A tool that only receives counts nothing the user
192
+ * would act on differently, so its tab says that something arrived and leaves the number out of it.
120
193
  */
121
- export function noteTool(id, { unread = 0, empty = false } = {}) {
122
- marks.set(id, { unread, empty });
194
+ export function noteTool(id, { unread = 0, empty = false, counted = true } = {}) {
195
+ marks.set(id, { unread, empty, counted });
123
196
  renderShell();
124
197
  }
125
198
 
126
- /** Open a tool, drawing its panel. A tool the user chose is the tool this window opens on next time. */
199
+ /** Open a tool, drawing its panel. A tool the user chose is the tool this session comes back on. */
127
200
  export function showTool(id) {
128
201
  if (!TOOLS.some((tool) => tool.id === id)) return;
129
- openTool = id;
130
- remember();
131
- renderShell();
132
- renderers.get(id)?.();
202
+ current.tool = id;
203
+ reshape();
133
204
  }
134
205
 
135
206
  function drawTabs() {
136
207
  const tabs = TOOLS.map((tool) => {
137
208
  const button = document.createElement("button");
138
209
  button.type = "button";
139
- button.className = tool.id === openTool ? "tool-tab on" : "tool-tab";
210
+ button.className = tool.id === current.tool ? "tool-tab on" : "tool-tab";
140
211
  button.dataset.tool = tool.id;
141
212
  button.append(tool.label);
142
213
  // What is waiting in a tool nobody is looking at. The one thing a tab says beyond its name, so a tool
143
214
  // never has to take the screen to be noticed.
144
- const waiting = (marks.get(tool.id) ?? EMPTY_MARK).unread;
145
- if (waiting > 0) {
215
+ const mark = marks.get(tool.id) ?? EMPTY_MARK;
216
+ if (mark.unread > 0) {
146
217
  const badge = document.createElement("span");
147
- badge.className = "tool-mark";
148
- badge.textContent = String(waiting);
218
+ badge.className = mark.counted ? "tool-mark" : "tool-mark dot";
219
+ if (mark.counted) badge.textContent = String(mark.unread);
220
+ else badge.setAttribute("aria-label", "something new");
221
+ // The ring, for a tool something landed in a moment ago. This element is new on every redraw, so
222
+ // the ring would start from the beginning each time; a negative delay of what has already run
223
+ // puts it back where it was.
224
+ const ring = pulsing.get(tool.id);
225
+ if (ring) {
226
+ badge.classList.add("pulse");
227
+ badge.style.setProperty("--ring-delay", `-${Date.now() - ring.at}ms`);
228
+ }
149
229
  button.append(badge);
150
230
  }
151
231
  button.addEventListener("click", () => showTool(tool.id));
152
232
  return button;
153
233
  });
154
234
  toolTabs.replaceChildren(...tabs);
155
- for (const tool of TOOLS) tool.panel.hidden = tool.id !== openTool;
235
+ for (const tool of TOOLS) tool.panel.hidden = tool.id !== current.tool;
156
236
  }
157
237
 
158
238
  // --- How wide ----------------------------------------------------------------------------------------------------------------------------
159
239
 
160
240
  // How wide, put away, or the whole row. Its own function because a drag is the one thing that changes the
161
241
  // pane without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
162
- function applyGeometry(collapsedNow = shut) {
163
- pane.classList.toggle("collapsed", collapsedNow);
242
+ function applyGeometry() {
243
+ const { width, full, collapsed } = current;
244
+ pane.classList.toggle("collapsed", collapsed);
164
245
  // Only one of the two can be true: a pane that is put away has no width to be full of.
165
- pane.classList.toggle("full", full && !collapsedNow);
246
+ pane.classList.toggle("full", full && !collapsed);
166
247
  // The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
167
248
  // last word: the collapsed spine, the full row and the narrow-screen layout are rules, and an inline
168
249
  // width would beat all three.
169
250
  pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
170
- paneFull.classList.toggle("on", full && !collapsedNow);
171
- const back = full && !collapsedNow;
251
+ paneFull.classList.toggle("on", full && !collapsed);
252
+ paneHalf.classList.toggle("on", isDefaultWidth(current));
253
+ const back = full && !collapsed;
172
254
  paneFull.title = back ? "Give the terminal its room back" : "Full width";
173
255
  paneFull.setAttribute("aria-label", paneFull.title);
174
256
  }
175
257
 
176
258
  /** Draw the pane's own chrome: the tabs, the width, and what the spine says while the pane is away. */
177
259
  export function renderShell() {
178
- const mark = marks.get(openTool) ?? EMPTY_MARK;
179
- shut = collapsed === null ? mark.empty : collapsed;
260
+ const mark = marks.get(current.tool) ?? EMPTY_MARK;
180
261
  pane.hidden = false;
181
262
  // There is no width worth choosing for a tool with nothing in it.
182
- grip.hidden = mark.empty && shut;
263
+ grip.hidden = mark.empty && current.collapsed;
183
264
 
184
- applyGeometry(shut);
265
+ applyGeometry();
185
266
  drawTabs();
186
267
 
187
268
  // The spine stands for the whole pane, so it carries what every tool is waiting on, not just the open one.
269
+ // It counts what the tools count, and falls back to the bare dot when all that arrived was uncounted:
270
+ // a number here has to mean the same thing it means on a tab.
188
271
  let waiting = 0;
189
- for (const entry of marks.values()) waiting += entry.unread;
272
+ let arrived = false;
273
+ for (const entry of marks.values()) {
274
+ if (entry.unread <= 0) continue;
275
+ if (entry.counted) waiting += entry.unread;
276
+ else arrived = true;
277
+ }
190
278
  spineBadge.textContent = waiting > 0 ? String(waiting) : "";
191
- spineBadge.hidden = waiting === 0;
279
+ spineBadge.classList.toggle("dot", waiting === 0 && arrived);
280
+ spineBadge.hidden = waiting === 0 && !arrived;
192
281
  }
193
282
 
194
283
  /**
195
284
  * Draw the pane for the first time, on the tool this window was left on. Called from the entry rather than
196
285
  * here, because the tools register as they load and there is no pane to draw until they all have.
197
286
  */
198
- export function mountPane() {
199
- renderShell();
200
- renderers.get(openTool)?.();
201
- }
202
-
203
- /** Whether the pane is drawn as the spine rather than as a pane. */
204
- export function isPaneShut() {
205
- return shut;
287
+ export function mountPane(startSid = null) {
288
+ sid = startSid ?? null;
289
+ current = shapeOf(sid);
290
+ redraw();
206
291
  }
207
292
 
208
- /** Whether the user put the pane away, as opposed to never having said. */
293
+ /** Whether the pane is put away in this session, which is whether it is drawn as the spine. */
209
294
  export function isPaneCollapsed() {
210
- return collapsed === true;
295
+ return current.collapsed;
211
296
  }
212
297
 
213
298
  // Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
214
299
  // resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
215
300
  export function setPaneCollapsed(next) {
216
- collapsed = next;
217
- shut = next;
218
- if (collapsed) {
301
+ current.collapsed = next;
302
+ if (current.collapsed) {
219
303
  // Coming back to a pane that fills the window, with the terminal nowhere, is not what putting one
220
304
  // away asks for. The width it had is remembered; the far end is not.
221
- full = false;
305
+ current.full = false;
222
306
  } else {
223
- // Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one
224
- // from a window remembered narrower than this one, has to open into something a page fits in.
225
- const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
226
- if (width !== null && width < floor) width = floor;
307
+ current.width = reopenedFrom(current, window.innerWidth);
227
308
  }
228
- remember();
229
- renderShell();
230
- renderers.get(openTool)?.();
309
+ reshape();
310
+ }
311
+
312
+ // Back to the width the pane opens at. Not a width of its own: the pane is left with none, and the
313
+ // stylesheet draws the default fraction, so a window resized afterwards leaves the pane at half rather
314
+ // than at the pixels half happened to be when the button was pressed.
315
+ function setHalf() {
316
+ current.width = null;
317
+ current.full = false;
318
+ current.collapsed = false;
319
+ reshape();
231
320
  }
232
321
 
233
322
  // The far end, and back. A press either way, and a drag of the grip is the other way back.
234
323
  function setFull(next) {
235
- full = next;
236
- if (full) collapsed = false;
237
- remember();
238
- renderShell();
239
- renderers.get(openTool)?.();
324
+ current.full = next;
325
+ if (current.full) current.collapsed = false;
326
+ reshape();
240
327
  }
241
328
 
242
- paneFull.addEventListener("click", () => setFull(!full));
329
+ paneFull.addEventListener("click", () => setFull(!current.full));
330
+ paneHalf.addEventListener("click", setHalf);
243
331
  paneCollapse.addEventListener("click", () => setPaneCollapsed(true));
244
332
  spine.addEventListener("click", () => setPaneCollapsed(false));
245
333
 
@@ -262,6 +350,33 @@ export function flashPane() {
262
350
  arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
263
351
  }
264
352
 
353
+ // Three turns of the ring in styles.css, which runs for 1.6s each. The two numbers have to agree: the
354
+ // keyframes are there and what takes the ring off again is here.
355
+ const TAB_PULSE_MS = 4_800;
356
+
357
+ // id -> when that tool's ring started, and the timer that ends it. A tab is rebuilt on every redraw, so the
358
+ // ring cannot be left on the element the way the pane's own flash is - what holds it is this map.
359
+ const pulsing = new Map();
360
+
361
+ /**
362
+ * Something landed in a tool the user is not looking at, so its tab draws a ring around its mark for three
363
+ * turns and then rests.
364
+ *
365
+ * A second arrival during those three turns starts them again, which is what the mark means: the tab says
366
+ * that something arrived just now, and the number beside it says how much is waiting.
367
+ */
368
+ export function pulseTool(id) {
369
+ clearTimeout(pulsing.get(id)?.timer);
370
+ pulsing.set(id, {
371
+ at: Date.now(),
372
+ timer: setTimeout(() => {
373
+ pulsing.delete(id);
374
+ renderShell();
375
+ }, TAB_PULSE_MS),
376
+ });
377
+ renderShell();
378
+ }
379
+
265
380
  // --- The divider -------------------------------------------------------------------------------------------------------------------------
266
381
 
267
382
  // Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
@@ -275,37 +390,27 @@ let widthBeforeDrag = null;
275
390
 
276
391
  grip.addEventListener("pointerdown", (event) => {
277
392
  dragging = true;
278
- widthBeforeDrag = width;
393
+ widthBeforeDrag = current.width;
279
394
  document.body.classList.add("dragging");
280
395
  grip.setPointerCapture(event.pointerId);
281
396
  });
282
397
 
283
398
  grip.addEventListener("pointermove", (event) => {
284
399
  if (!dragging) return;
285
- // A drag is a width, so it is the way out of the far end as well as the way into any other width.
286
- full = false;
287
400
  const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
288
- if (wanted < COLLAPSE_AT) {
289
- collapsed = true;
290
- width = widthBeforeDrag;
291
- } else {
292
- collapsed = false;
293
- width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
294
- }
295
- // A drag is an answer too, so what it decides is what the next render starts from.
296
- shut = collapsed;
297
- applyGeometry(shut);
401
+ // A drag is a width, so it is the way into every state the pane has and the way out of each of them.
402
+ // Where each of those states begins is pane-shape.js's rule; what is left here is the pointer.
403
+ Object.assign(current, draggedTo(wanted, window.innerWidth, widthBeforeDrag));
404
+ applyGeometry();
298
405
  });
299
406
 
300
407
  function endDrag() {
301
408
  if (!dragging) return;
302
409
  dragging = false;
303
410
  document.body.classList.remove("dragging");
304
- remember();
305
- // A drag that crossed the threshold changed which of the three states the pane is in, and the tool that
306
- // is open in it has a bar and a document to lay out again at the new width.
307
- renderShell();
308
- renderers.get(openTool)?.();
411
+ // The shape changed under the pointer, and only the geometry followed it. A drag that crossed either
412
+ // threshold also changed which of the three states the pane is in, which the tool has to hear about.
413
+ reshape();
309
414
  }
310
415
 
311
416
  grip.addEventListener("pointerup", endDrag);