chamba 0.3.1 → 0.4.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -23
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1582
  83. package/dist/server.js +0 -1831
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
@@ -0,0 +1,472 @@
1
+ // alerts.js - what the interface says when nobody is looking at it: the browser's own tab, and a sound.
2
+ //
3
+ // Everything the session bar does is invisible when the window is behind something else, and that is exactly
4
+ // when an agent finishing matters most - one browser tab per workspace, the user in an editor or in another
5
+ // workspace. So the same two facts are said again in the only places a hidden window can speak.
6
+
7
+ import { SVG_NS } from "./dom.js";
8
+ import { sessions, workspaceName } from "./state.js";
9
+ import { arrivedAt, renderBar } from "./tabs.js";
10
+ import { focusTerminal } from "./terminal.js";
11
+ import { WORKSPACE_COLOR } from "./theme.js";
12
+
13
+ // --- The browser's own tab ---------------------------------------------------------------------------------------------------------------
14
+ //
15
+ // The icon is drawn here rather than shipped as a file because it has to carry live state. It keeps the spark
16
+ // (that is the product's mark, and its gold is the mark's own, not the interface's palette) and adds a thin
17
+ // frame in the workspace colour, so a row of pinned tabs is readable at a glance. On top of that it carries the
18
+ // state of the container in one mark:
19
+ //
20
+ // nothing - nothing is happening in here
21
+ // white bar across the bottom - an agent is working, with a lit segment sweeping along it
22
+ // green dot in the top corner - an agent finished and is waiting for you, pulsing until you go and look
23
+ //
24
+ // One mark at a time, and the two are told apart by where they sit before any colour is read: working owns the
25
+ // bottom edge, waiting owns the top-right corner. That was the lesson of the first version, where both were a
26
+ // dot in the same corner - at 16px, which is the only size that really matters here, a blue dot and a green dot
27
+ // are the same dot. Green outranks white, since something that wants you matters more than something still going.
28
+ //
29
+ // Working is white rather than a colour on purpose. Every hue in this interface belongs to a workspace or to a
30
+ // session, and the frame around this very icon is one of six of them - so a blue mark sat inside a blue frame in
31
+ // one workspace out of six and stopped reading as a mark at all. White belongs to nobody, and it leaves green as
32
+ // the only hue in the icon that means anything, which is what makes the pair read as a traffic light.
33
+ //
34
+ // Both marks move, and neither leans on a particular frame to be understood. A hidden tab is exactly the tab this
35
+ // icon exists for, and a browser slows a hidden tab's timers to a second and then to one a minute, so whatever
36
+ // frame the animation stalls on has to say what the rest of them say: waiting pulses between a bright dot and a
37
+ // dim one rather than between a dot and nothing, and the working segment is the lit part of a bar that is drawn
38
+ // whole underneath it. The worst a throttled tab does is move slowly, or stop - and stopped still reads.
39
+
40
+ const faviconLink = document.querySelector('link[rel="icon"]');
41
+ // Drawn at 2x the nominal 32px so the downscale to 16px stays crisp.
42
+ const ICON_SIZE = 64;
43
+ const ICON_UNITS = 32;
44
+ // One step of the icon's own clock, which runs only while the icon has something to say. Everything that moves is
45
+ // a multiple of this, so the whole icon is one timer and one counter rather than an animation each.
46
+ const ICON_TICK_MS = 240;
47
+ // The waiting dot's bright-to-dim step, in ticks. Slow enough to read as a pulse rather than a flicker.
48
+ const PULSE_TICKS = 3;
49
+ // One out-and-back of the working segment, in ticks - about two seconds each way, which is a sweep and not a dash.
50
+ const SWEEP_TICKS = 16;
51
+ const DONE_COLOR = "#2fe58a";
52
+ const BUSY_COLOR = "#e6edf3";
53
+
54
+ let iconCanvas = null;
55
+ let iconTimer = null;
56
+ let iconFrame = 0;
57
+
58
+ // What the dot should say right now, or null for no dot. Read fresh on every paint rather than passed in, so a
59
+ // paint from anywhere - an incoming bar, or the pulse - draws what is true now.
60
+ function dotColor() {
61
+ if (sessions.some((entry) => entry.attention)) return DONE_COLOR;
62
+ if (sessions.some((entry) => entry.working)) return BUSY_COLOR;
63
+ return null;
64
+ }
65
+
66
+ // Where the waiting pulse is in its cycle: bright, then dim, then bright again.
67
+ function pulseDim() {
68
+ return Math.floor(iconFrame / PULSE_TICKS) % 2 === 1;
69
+ }
70
+
71
+ // Where the working segment is on its track - 0 at one end, 1 at the other, and back down again. A triangle
72
+ // rather than a saw, so the segment sweeps back instead of jumping to the start.
73
+ function sweepAt() {
74
+ const half = SWEEP_TICKS / 2;
75
+ const step = iconFrame % SWEEP_TICKS;
76
+ return (step < half ? step : SWEEP_TICKS - step) / half;
77
+ }
78
+
79
+ // The spark, on the panel dark, with the workspace frame, and the state mark on top of it.
80
+ function paintFavicon() {
81
+ if (!faviconLink) return;
82
+ iconCanvas ??= document.createElement("canvas");
83
+ // Setting the size clears the canvas and resets the transform, so every paint starts from nothing.
84
+ iconCanvas.width = ICON_SIZE;
85
+ iconCanvas.height = ICON_SIZE;
86
+ const ctx = iconCanvas.getContext("2d");
87
+ if (!ctx) return;
88
+ const badgeColor = dotColor();
89
+ // Only one mark is ever drawn, so both phases can be handed over and the drawing picks the one it needs.
90
+ try {
91
+ drawIcon(ctx, badgeColor, badgeColor === DONE_COLOR && pulseDim(), sweepAt());
92
+ } catch {
93
+ // A drawing call this browser does not have (roundRect is recent) must not reach the frame handler
94
+ // that got here: the shipped favicon.svg stays, and everything else on the page carries on.
95
+ return;
96
+ }
97
+ faviconLink.type = "image/png";
98
+ faviconLink.href = iconCanvas.toDataURL("image/png");
99
+ }
100
+
101
+ function drawIcon(ctx, badgeColor, dim, sweep) {
102
+ ctx.scale(ICON_SIZE / ICON_UNITS, ICON_SIZE / ICON_UNITS);
103
+
104
+ // Panel, and the frame that says which workspace this tab belongs to.
105
+ ctx.fillStyle = "#0d1117";
106
+ ctx.beginPath();
107
+ ctx.roundRect(0, 0, 32, 32, 7);
108
+ ctx.fill();
109
+ ctx.strokeStyle = WORKSPACE_COLOR;
110
+ ctx.lineWidth = 2;
111
+ ctx.beginPath();
112
+ ctx.roundRect(1, 1, 30, 30, 6);
113
+ ctx.stroke();
114
+
115
+ // The spark: four points with a waist, stroked as well as filled so it nearly fills the icon and still
116
+ // reads at 16px. Same geometry and gradient as favicon.svg, which is what a browser without canvas still
117
+ // gets - the two are one drawing in two languages, so a change to either is a change to both.
118
+ const spark = ctx.createLinearGradient(0, 0, 0, 32);
119
+ spark.addColorStop(0, "#f0c078");
120
+ spark.addColorStop(1, "#d08a3c");
121
+ ctx.fillStyle = spark;
122
+ ctx.strokeStyle = spark;
123
+ ctx.lineWidth = 2;
124
+ ctx.lineJoin = "round";
125
+ ctx.beginPath();
126
+ ctx.moveTo(16, 3);
127
+ ctx.quadraticCurveTo(17.6, 14.4, 29, 16);
128
+ ctx.quadraticCurveTo(17.6, 17.6, 16, 29);
129
+ ctx.quadraticCurveTo(14.4, 17.6, 3, 16);
130
+ ctx.quadraticCurveTo(14.4, 14.4, 16, 3);
131
+ ctx.closePath();
132
+ ctx.fill();
133
+ ctx.stroke();
134
+
135
+ // Working: a bar across the bottom, with a lit segment sweeping from end to end. The bottom edge is the far
136
+ // side of the icon from the corner waiting owns, which is what tells the two states apart at 16px - a thin
137
+ // line down the right edge, which is what this used to be, reads as a scrollbar rather than as anything the
138
+ // container is doing. The track is 20 wide and the segment 8, so the segment travels the 12 between them.
139
+ // Every mark is punched out of the icon first, so it stays legible over the spark's own gold and the frame.
140
+ if (badgeColor === BUSY_COLOR) {
141
+ ctx.fillStyle = "#0d1117";
142
+ ctx.beginPath();
143
+ ctx.roundRect(4.5, 24, 23, 6, 3);
144
+ ctx.fill();
145
+ // Drawn whole, at a quarter strength, so the bar is there whatever frame a throttled tab stalls on.
146
+ ctx.fillStyle = badgeColor;
147
+ ctx.globalAlpha = 0.25;
148
+ ctx.beginPath();
149
+ ctx.roundRect(6, 25.5, 20, 3.5, 1.75);
150
+ ctx.fill();
151
+ ctx.globalAlpha = 1;
152
+ ctx.beginPath();
153
+ ctx.roundRect(6 + sweep * 12, 25.5, 8, 3.5, 1.75);
154
+ ctx.fill();
155
+ return;
156
+ }
157
+
158
+ // Waiting: a dot in the top-right corner, pulsing between bright and dim. The punch-out never changes size,
159
+ // so the pulse moves the dot and nothing else.
160
+ if (badgeColor) {
161
+ ctx.fillStyle = "#0d1117";
162
+ ctx.beginPath();
163
+ ctx.arc(24, 8, 6.5, 0, Math.PI * 2);
164
+ ctx.fill();
165
+ ctx.fillStyle = badgeColor;
166
+ ctx.globalAlpha = dim ? 0.45 : 1;
167
+ ctx.beginPath();
168
+ ctx.arc(24, 8, dim ? 3.4 : 4.8, 0, Math.PI * 2);
169
+ ctx.fill();
170
+ ctx.globalAlpha = 1;
171
+ }
172
+ }
173
+
174
+ // The clock runs only while the icon has a mark to draw, and stops the moment it does not. It re-checks the state
175
+ // itself rather than trusting the bar that started it, so a session visited in this window (or in another one)
176
+ // puts the icon back to a plain spark without waiting for anything else to happen.
177
+ function tickIcon() {
178
+ iconTimer = null;
179
+ if (!dotColor()) {
180
+ iconFrame = 0;
181
+ paintFavicon();
182
+ return;
183
+ }
184
+ // Riding the one timer that is already running for exactly as long as something is happening. A hidden tab's
185
+ // own timers are throttled hard, so the chime is better off not depending on any single one of them.
186
+ maybeChime();
187
+ iconFrame++;
188
+ paintFavicon();
189
+ iconTimer = setTimeout(tickIcon, ICON_TICK_MS);
190
+ }
191
+
192
+ // Title and icon together, from the sessions the server last sent. The count goes in the title because that
193
+ // is what a window list and a taskbar show; the dot does the work in a tab strip too narrow for words.
194
+ export function refreshBrowserTab() {
195
+ // Just the workspace, no product name: a tab strip gives you a few characters, and the icon already says
196
+ // this is chamba. What the title is for is which workspace, and how many sessions want you.
197
+ const waiting = sessions.filter((entry) => entry.attention);
198
+ const name = workspaceName || "chamba";
199
+ document.title = waiting.length > 0 ? `(${waiting.length}) ${name}` : name;
200
+
201
+ // Painted from the state every time, moving or not. Starting the clock is guarded by the timer rather than by
202
+ // what the sessions are doing: an alert arriving next to one already up is the same state, and restarting the
203
+ // cycle on every frame the server sends would make the mark stutter.
204
+ paintFavicon();
205
+ if (!dotColor()) {
206
+ if (iconTimer) clearTimeout(iconTimer);
207
+ iconTimer = null;
208
+ iconFrame = 0;
209
+ return;
210
+ }
211
+ if (!iconTimer) iconTimer = setTimeout(tickIcon, ICON_TICK_MS);
212
+ }
213
+
214
+ // --- A sound, for the session you are not at ---------------------------------------------------------------------------------------------
215
+ //
216
+ // The fourth thing the "an agent finished" alert does, and the only one that reaches a window behind an editor.
217
+ // Everything above this - the tab, the title, the icon - has to be looked at to be read, so all three fire on every
218
+ // ending whoever is watching. A sound cannot be taken back and reaches you in the next room, so it is the one that asks
219
+ // whether you are there.
220
+ //
221
+ // It asks it by waiting. An alert stands until someone touches that session, so an alert still standing a few seconds
222
+ // after the turn ended is one nobody has come back to, and that is the whole test - touch it inside the hold, by
223
+ // typing, clicking or scrolling, and the sound is called off before it is ever heard. Deliberately not by whether this
224
+ // window has focus, which is what this used to do: focus varies by browser and platform, has to be re-reported on every
225
+ // reconnect, and each hole in it fails towards silence.
226
+ //
227
+ // Nothing before the ending counts, the prompt that started the turn included. Sitting and watching a reply arrive is
228
+ // not using the session, and a rule that treated it as such is what made a short turn silent for good.
229
+ //
230
+ // It is synthesised rather than played from a file, for the same reason the favicon above is drawn rather than shipped:
231
+ // nothing to fetch, nothing to license, and the whole sound is legible right here as three numbers times three.
232
+
233
+ // How long an alert has to keep standing before it is worth a sound. On top of the registry's own settle, so a chime
234
+ // always means "this finished a few seconds ago and nobody has been near it", never "it went quiet for a moment".
235
+ // Short on purpose: this is the only thing between a finish and your ears, and a finish you hear about a minute late
236
+ // is one you have already given up on.
237
+ const CHIME_HOLD_MS = 10_000;
238
+ // A floor between chimes, for alerts that come due a moment apart rather than together.
239
+ const CHIME_GAP_MS = 2_000;
240
+ // How long one window's chime speaks for every window of this workspace. Two of them left open on the same container
241
+ // both see the same alert, and one sound is the entire point.
242
+ const CHIME_CLAIM_MS = 3_000;
243
+ const SOUND_KEY = "webterm-sound";
244
+ const CHIME_CLAIM_KEY = "webterm-chime-claim";
245
+
246
+ // A glass tap. A struck object is a handful of sine partials, each fading at its own rate, and the inharmonic ones
247
+ // (2.7 and 5.2 rather than 2 and 3) are what stop it sounding like an organ note. [ratio of the base note, how loud
248
+ // against the fundamental, how long it takes to fade].
249
+ const CHIME_PARTIALS = [
250
+ [1, 1, 0.7],
251
+ [2.7, 0.25, 0.35],
252
+ [5.2, 0.08, 0.2],
253
+ ];
254
+ const CHIME_FREQ = 1568;
255
+ const CHIME_ATTACK = 0.002;
256
+ // How hard it is struck, and how loud the room plays it back. Kept apart because the first is the instrument and the
257
+ // second is the volume knob.
258
+ const CHIME_PEAK = 0.4;
259
+ const CHIME_VOLUME = 0.35;
260
+
261
+ // Muting is remembered for this browser and this workspace - one container, one port, one origin - so it survives a
262
+ // reload and covers every window of the same container without touching any other workspace. On by default: a
263
+ // notification nobody discovers is not a notification, and the bell is right there to turn off.
264
+ let soundOn = readSoundPref();
265
+
266
+ function readSoundPref() {
267
+ try {
268
+ return localStorage.getItem(SOUND_KEY) !== "off";
269
+ } catch {
270
+ // A browser with storage turned off still gets the sound; it just cannot remember being told not to.
271
+ return true;
272
+ }
273
+ }
274
+
275
+ let audioCtx = null;
276
+ let chimeDry = null;
277
+ let chimeWet = null;
278
+
279
+ // A small room, made of noise that fades out. A convolver needs an impulse and this is the cheapest honest one; it is
280
+ // what makes the tap sound like it happened somewhere rather than inside your head.
281
+ function buildRoom(ctx) {
282
+ const length = Math.floor(ctx.sampleRate * 1.8);
283
+ const impulse = ctx.createBuffer(2, length, ctx.sampleRate);
284
+ for (let channel = 0; channel < 2; channel++) {
285
+ const data = impulse.getChannelData(channel);
286
+ for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3.2;
287
+ }
288
+ const convolver = ctx.createConvolver();
289
+ convolver.buffer = impulse;
290
+ return convolver;
291
+ }
292
+
293
+ // Built on first use and kept. A browser will not let a page make a sound before the page has been interacted with, so
294
+ // this can be called at any moment and simply not be running yet.
295
+ function audio() {
296
+ if (!audioCtx) {
297
+ const Ctor = window.AudioContext ?? window.webkitAudioContext;
298
+ if (!Ctor) return null;
299
+ audioCtx = new Ctor();
300
+ const master = audioCtx.createGain();
301
+ master.gain.value = CHIME_VOLUME;
302
+ // Takes the edge off the top partial. Something you might hear all day should not be bright.
303
+ const soften = audioCtx.createBiquadFilter();
304
+ soften.type = "lowpass";
305
+ soften.frequency.value = 9000;
306
+ soften.Q.value = 0.4;
307
+ chimeDry = audioCtx.createGain();
308
+ chimeDry.gain.value = 0.78;
309
+ chimeWet = audioCtx.createGain();
310
+ chimeWet.gain.value = 0.42;
311
+ chimeDry.connect(soften);
312
+ chimeWet.connect(buildRoom(audioCtx)).connect(soften);
313
+ soften.connect(master).connect(audioCtx.destination);
314
+ }
315
+ if (audioCtx.state === "suspended") audioCtx.resume();
316
+ return audioCtx;
317
+ }
318
+
319
+ // One tap. Breaks nothing when it cannot play: a sound is never the only thing carrying the alert.
320
+ function playChime() {
321
+ const ctx = audio();
322
+ // Blocked until this page has been clicked or typed into. Nearly never true here - you click the terminal to type -
323
+ // and the tab, the title and the icon are all still saying it either way.
324
+ if (!ctx || ctx.state !== "running") return;
325
+ const at = ctx.currentTime + 0.02;
326
+ for (const [ratio, level, decay] of CHIME_PARTIALS) {
327
+ const osc = ctx.createOscillator();
328
+ osc.frequency.value = CHIME_FREQ * ratio;
329
+ const env = ctx.createGain();
330
+ // Exponential, and never to zero, which is the one value an exponential ramp cannot reach.
331
+ env.gain.setValueAtTime(0.0001, at);
332
+ env.gain.exponentialRampToValueAtTime(Math.max(0.0002, level * CHIME_PEAK), at + CHIME_ATTACK);
333
+ env.gain.exponentialRampToValueAtTime(0.0001, at + CHIME_ATTACK + decay);
334
+ osc.connect(env);
335
+ env.connect(chimeDry);
336
+ env.connect(chimeWet);
337
+ osc.start(at);
338
+ osc.stop(at + CHIME_ATTACK + decay + 0.05);
339
+ }
340
+ }
341
+
342
+ // The earliest honest moment to open the audio context. The interface is interacted with immediately - the terminal is
343
+ // clicked or typed into before anything interesting happens - so by the time an agent has finished a turn this has run.
344
+ function unlockAudio() {
345
+ audio();
346
+ }
347
+
348
+ document.addEventListener("pointerdown", unlockAudio, { once: true });
349
+ document.addEventListener("keydown", unlockAudio, { once: true });
350
+
351
+ // Alerts that have already been sounded, so one finish makes one sound however many frames the server sends. Pruned
352
+ // alongside arrivedAt, which is what lets a session that finishes, is visited, and finishes again sound both times.
353
+ const chimed = new Set();
354
+ let chimeTimer = null;
355
+ let lastChimeAt = 0;
356
+
357
+ // A session has stopped waiting, so the alert it sounded for is spent and the next finish is a new one. Called from
358
+ // the bar's arrival bookkeeping, which is where a session stops waiting is noticed.
359
+ export function forgetChime(sid) {
360
+ chimed.delete(sid);
361
+ }
362
+
363
+ // Alerts that have stood long enough to be worth hearing about. Still standing is the answer to "did anyone come
364
+ // back": a touch puts the alert out at the server, and it drops out of here on the next frame.
365
+ function dueAlerts() {
366
+ const now = Date.now();
367
+ return sessions.filter((entry) => entry.attention && !chimed.has(entry.id) && now - (arrivedAt.get(entry.id) ?? now) >= CHIME_HOLD_MS);
368
+ }
369
+
370
+ // One window speaks for the workspace. Best effort - two windows racing on the same millisecond both chime, which is
371
+ // exactly what would have happened without this.
372
+ function claimChime() {
373
+ try {
374
+ const previous = Number(localStorage.getItem(CHIME_CLAIM_KEY) ?? 0);
375
+ if (Date.now() - previous < CHIME_CLAIM_MS) return false;
376
+ localStorage.setItem(CHIME_CLAIM_KEY, String(Date.now()));
377
+ } catch {
378
+ // Nowhere to share the claim, so this window speaks for itself. One window chiming is still right.
379
+ }
380
+ return true;
381
+ }
382
+
383
+ // Called from the two clocks already running: an incoming frame schedules the exact moment, and the icon's own
384
+ // clock - which ticks for as long as anything is working or waiting and no longer - catches it when a hidden tab's
385
+ // timers are being throttled. Whichever arrives first plays; the other finds the alert already spent.
386
+ function maybeChime() {
387
+ const due = dueAlerts();
388
+ if (due.length === 0) return;
389
+ // Spent whether or not it is heard, so a muted window does not save up its chimes for whenever the bell is
390
+ // unmuted - nothing new would have happened by then.
391
+ for (const entry of due) chimed.add(entry.id);
392
+ if (!soundOn) return;
393
+ const now = Date.now();
394
+ if (now - lastChimeAt < CHIME_GAP_MS) return;
395
+ if (!claimChime()) return;
396
+ lastChimeAt = now;
397
+ // Several alerts coming due together are one sound. The message is "come back", not "come back twice".
398
+ playChime();
399
+ }
400
+
401
+ // One timer for the whole bar, set to the next alert that comes due, and re-armed on every frame - so an alert spent
402
+ // in the meantime takes its timer with it rather than firing on nothing.
403
+ export function scheduleChime() {
404
+ if (chimeTimer) {
405
+ clearTimeout(chimeTimer);
406
+ chimeTimer = null;
407
+ }
408
+ const now = Date.now();
409
+ let soonest = Number.POSITIVE_INFINITY;
410
+ for (const entry of sessions) {
411
+ if (!entry.attention || chimed.has(entry.id)) continue;
412
+ soonest = Math.min(soonest, (arrivedAt.get(entry.id) ?? now) + CHIME_HOLD_MS);
413
+ }
414
+ if (soonest === Number.POSITIVE_INFINITY) return;
415
+ chimeTimer = setTimeout(maybeChime, Math.max(0, soonest - now));
416
+ }
417
+
418
+ // Sound off, and back on. It lives beside the power button because both are about this window and this container
419
+ // rather than any one session - with a little distance between them, since a harmless toggle should not share an edge
420
+ // with the one control that ends everything.
421
+ export function bellButton() {
422
+ const button = document.createElement("button");
423
+ button.id = "bellbtn";
424
+ button.type = "button";
425
+ button.classList.toggle("muted", !soundOn);
426
+ button.title = soundOn
427
+ ? "Sound on - a chime when an agent finishes in a session you have not touched in a while. Click to mute."
428
+ : "Sound muted - click for a chime when an agent finishes in a session you have not touched in a while.";
429
+ button.setAttribute("aria-label", soundOn ? "Mute the finish sound" : "Unmute the finish sound");
430
+ button.setAttribute("aria-pressed", String(soundOn));
431
+ const svg = document.createElementNS(SVG_NS, "svg");
432
+ svg.setAttribute("viewBox", "0 0 24 24");
433
+ svg.setAttribute("width", "14");
434
+ svg.setAttribute("height", "14");
435
+ svg.setAttribute("fill", "none");
436
+ svg.setAttribute("stroke", "currentColor");
437
+ svg.setAttribute("stroke-width", "2");
438
+ svg.setAttribute("stroke-linecap", "round");
439
+ svg.setAttribute("stroke-linejoin", "round");
440
+ const bell = document.createElementNS(SVG_NS, "path");
441
+ bell.setAttribute("d", "M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9");
442
+ const clapper = document.createElementNS(SVG_NS, "path");
443
+ clapper.setAttribute("d", "M13.73 21a2 2 0 0 1-3.46 0");
444
+ svg.append(bell, clapper);
445
+ // Struck through when muted, so the state is a shape and not only a shade.
446
+ if (!soundOn) {
447
+ const slash = document.createElementNS(SVG_NS, "line");
448
+ slash.setAttribute("x1", "3");
449
+ slash.setAttribute("y1", "3");
450
+ slash.setAttribute("x2", "21");
451
+ slash.setAttribute("y2", "21");
452
+ svg.append(slash);
453
+ }
454
+ button.append(svg);
455
+ button.addEventListener("click", toggleSound);
456
+ return button;
457
+ }
458
+
459
+ function toggleSound() {
460
+ soundOn = !soundOn;
461
+ try {
462
+ localStorage.setItem(SOUND_KEY, soundOn ? "on" : "off");
463
+ } catch {
464
+ // Not remembered past this page, but the toggle still works for as long as it is open.
465
+ }
466
+ renderBar();
467
+ // The click landed on a button, which took the keyboard off the terminal.
468
+ focusTerminal();
469
+ // Turning it on plays it once, so the first time it happens behind your editor it is a sound you have already
470
+ // agreed to. Turning it off says nothing, which is the whole point of turning it off.
471
+ if (soundOn) playChime();
472
+ }
@@ -0,0 +1,123 @@
1
+ // cards.js - the overlay cards.
2
+ //
3
+ // One card shape for every decision: a title, an explanation, and buttons. Each button closes the card and then
4
+ // runs its action, so no card can be left open over a terminal you are typing into. Cards are for questions the
5
+ // user did not go looking for - a confirmation, a refusal - which is why they sit in the middle of the screen;
6
+ // anything opened on purpose from the bar is a panel anchored to what opened it.
7
+
8
+ import { sendFrame } from "./connection.js";
9
+ import { card, overlay } from "./dom.js";
10
+ import { agents, labelOf, maxSessions, sessions } from "./state.js";
11
+ import { ageLabel } from "./tabs.js";
12
+
13
+ function showCard(title, body, buttons) {
14
+ card.textContent = "";
15
+ const heading = document.createElement("h2");
16
+ heading.textContent = title;
17
+ const text = document.createElement("p");
18
+ text.textContent = body;
19
+ const row = document.createElement("div");
20
+ row.className = "row";
21
+ for (const spec of buttons) {
22
+ const button = document.createElement("button");
23
+ button.type = "button";
24
+ button.className = spec.kind ? `btn ${spec.kind}` : "btn";
25
+ button.textContent = spec.label;
26
+ button.addEventListener("click", () => {
27
+ hideCard();
28
+ spec.run?.();
29
+ });
30
+ row.append(button);
31
+ }
32
+ card.append(heading, text, row);
33
+ overlay.classList.add("show");
34
+ }
35
+
36
+ function hideCard() {
37
+ overlay.classList.remove("show");
38
+ card.textContent = "";
39
+ }
40
+
41
+ // Asked when this window tries to open a session another window is driving.
42
+ export function takeoverCard(sid) {
43
+ showCard(
44
+ `${labelOf(sid, "That session")} is open in another browser window`,
45
+ "A terminal has one size, so only one window drives a session at a time. Switching here disconnects the other " +
46
+ "window - it keeps the conversation on screen and can take it back with one click.",
47
+ [{ label: "Cancel" }, { label: "Switch to this window", kind: "primary", run: () => sendFrame({ t: "takeover", sid }) }],
48
+ );
49
+ }
50
+
51
+ // Shown to the window that just lost a session to another one.
52
+ export function takenCard(sid) {
53
+ showCard(
54
+ `${labelOf(sid, "This session")} was taken over in another window`,
55
+ "Nothing was lost: the conversation is still running, and the screen below is where it was. Take it back whenever you want.",
56
+ [{ label: "Stay here" }, { label: "Take it back", kind: "primary", run: () => sendFrame({ t: "takeover", sid }) }],
57
+ );
58
+ }
59
+
60
+ // The only close a user can ask for, so it names what dies and how long it has been alive.
61
+ export function closeCard(entry) {
62
+ showCard(
63
+ `End ${entry.label}?`,
64
+ `The ${entry.agent} process is killed and this conversation stops. Sessions are never closed for you - ` +
65
+ `this one has been running ${ageLabel(entry.createdAt)}.`,
66
+ [{ label: "Cancel" }, { label: "End session", kind: "danger", run: () => sendFrame({ t: "close", sid: entry.id }) }],
67
+ );
68
+ }
69
+
70
+ // The server refused the path, so nothing was started. It does not say which path back: the user just typed
71
+ // it, and the rule is what they need.
72
+ export function badDirCard() {
73
+ showCard(
74
+ "That directory is not in the workspace",
75
+ "Nothing was started. The path has to be a directory that already exists inside the workspace - written " +
76
+ 'relative to its root, like "packages/api".',
77
+ [{ label: "Got it", kind: "primary" }],
78
+ );
79
+ }
80
+
81
+ // The server was asked for an agent it does not run. Only reachable from a stale page or a hand-made frame -
82
+ // the menu is built from what the server said it runs - so it says the rule and nothing more.
83
+ export function badAgentCard() {
84
+ showCard(
85
+ "That is not an agent this container runs",
86
+ `Nothing was started. This interface runs ${agents.join(", ")}. Reload the page if the list above looks wrong.`,
87
+ [{ label: "Got it", kind: "primary" }],
88
+ );
89
+ }
90
+
91
+ export function capCard() {
92
+ showCard(
93
+ `${maxSessions} sessions is the limit`,
94
+ "Each live agent holds a few hundred MB in the container, so the limit keeps one workspace from eating your " +
95
+ "memory. End a session you are done with and this one will start.",
96
+ [{ label: "Got it", kind: "primary" }],
97
+ );
98
+ }
99
+
100
+ // Asked before the container goes down, because it takes everything with it - the browser sessions here,
101
+ // and any terminal session open in the same container.
102
+ export function stopCard() {
103
+ const ends =
104
+ sessions.length === 0
105
+ ? "No agent sessions are running here. Stopping it also ends any terminal session open in the same container."
106
+ : `This ends ${sessions.length === 1 ? "the 1 agent session" : `all ${sessions.length} agent sessions`} in it, ` +
107
+ "and any terminal session open in it too.";
108
+ showCard("Stop the container?", `${ends} Nothing on disk is touched, and your next chamba session starts the container back up.`, [
109
+ { label: "Cancel" },
110
+ { label: "Stop container", kind: "danger", run: () => sendFrame({ t: "stop" }) },
111
+ ]);
112
+ }
113
+
114
+ // Shown when the container was asked to stop and did not - a container started before chamba gave its
115
+ // keep-alive a TERM trap cannot be stopped from inside itself.
116
+ export function stopFailedCard() {
117
+ showCard(
118
+ "The container did not stop",
119
+ "This container was created before chamba could stop one from the browser. Stop it from the host instead - " +
120
+ "npx chamba, or docker stop - and the next container it creates will take the button.",
121
+ [{ label: "Got it", kind: "primary" }],
122
+ );
123
+ }