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,1193 @@
1
+ /* styles.css - Dark layout: session bar on top, the attached session's terminal filling the middle,
2
+ and a composer below with the action buttons floating in the textbox's bottom-right corner. */
3
+
4
+ :root {
5
+ --bg: #0d1117;
6
+ --panel: #161b22;
7
+ --border: #30363d;
8
+ --text: #e6edf3;
9
+ --muted: #8b949e;
10
+ --bad: #f85149;
11
+ /* A touch lighter than pure black so the terminal does not read as a void. */
12
+ --term-bg: #12161d;
13
+ /* This workspace's colour, replaced by app/theme.js from the palette before anything is drawn. Everything that
14
+ says "you are in this window" is this one hue: the composer border, the terminal edge, the active tab's
15
+ edge, and the buttons. The value here is only what shows if the script never runs. */
16
+ --ws: #2fe58a;
17
+ /* The interface has one accent and it is the workspace's, so a window is one colour and not two. */
18
+ --accent: var(--ws);
19
+ }
20
+
21
+ * {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ html,
26
+ body {
27
+ margin: 0;
28
+ height: 100%;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
32
+ }
33
+
34
+ body {
35
+ display: flex;
36
+ flex-direction: column;
37
+ }
38
+
39
+ /* ---- Session bar ---- */
40
+
41
+ #tabbar {
42
+ display: flex;
43
+ align-items: stretch;
44
+ gap: 4px;
45
+ padding: 6px 12px 0;
46
+ background: var(--panel);
47
+ border-bottom: 1px solid var(--border);
48
+ overflow-x: auto;
49
+ }
50
+
51
+ /* Each tab carries its session's colour in --sc, set inline when the bar is rendered. */
52
+ .tab {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 8px;
56
+ padding: 7px 9px 7px 11px;
57
+ border: 1px solid var(--border);
58
+ border-bottom: none;
59
+ border-radius: 8px 8px 0 0;
60
+ background: #10151c;
61
+ color: var(--muted);
62
+ font-size: 12.5px;
63
+ line-height: 1;
64
+ white-space: nowrap;
65
+ cursor: pointer;
66
+ position: relative;
67
+ top: 1px;
68
+ /* A stacking context of its own, so the travelling light below can sit between the tab's surface and its
69
+ label rather than being pushed behind the whole bar. */
70
+ isolation: isolate;
71
+ }
72
+
73
+ .tab:hover {
74
+ color: var(--text);
75
+ }
76
+
77
+ /* Drag to reorder. The tab being dragged fades so the bar reads as a row with a hole in it, and the line
78
+ showing where it would land is drawn in the 4px gap on one side of the tab under the pointer. */
79
+ .tab[draggable="true"] {
80
+ cursor: grab;
81
+ }
82
+
83
+ .tab.dragging {
84
+ opacity: 0.4;
85
+ cursor: grabbing;
86
+ }
87
+
88
+ .tab.drop-before::before,
89
+ .tab.drop-after::after {
90
+ content: "";
91
+ position: absolute;
92
+ top: 2px;
93
+ bottom: 0;
94
+ width: 2px;
95
+ border-radius: 1px;
96
+ background: var(--accent);
97
+ }
98
+
99
+ .tab.drop-before::before {
100
+ left: -3px;
101
+ }
102
+
103
+ .tab.drop-after::after {
104
+ right: -3px;
105
+ }
106
+
107
+ /* The inset top edge is the workspace's colour, which also visually joins the tab to the terminal below it. */
108
+ .tab.active {
109
+ background: var(--term-bg);
110
+ color: var(--text);
111
+ box-shadow: inset 0 2px 0 var(--ws);
112
+ }
113
+
114
+ /* The session's identity, and only that: one solid disc, always shown, never animated. Everything a session
115
+ is doing is said by the tab around it, which leaves the dot free to just mean "this one". */
116
+ .tab .dot {
117
+ flex: none;
118
+ width: 8px;
119
+ height: 8px;
120
+ border-radius: 50%;
121
+ background: var(--sc);
122
+ }
123
+
124
+ /* Output arrived while this window was looking elsewhere, but not enough of it to count as work finishing:
125
+ the label brightens and the dot takes a thin ring. Nothing moves - movement belongs to the two states
126
+ below, so a session that merely said something cannot be mistaken for one that wants you. */
127
+ .tab.unread {
128
+ color: var(--text);
129
+ }
130
+
131
+ .tab.unread .dot {
132
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--sc) 22%, transparent);
133
+ }
134
+
135
+ /* ---- The agent is working ---- */
136
+ /*
137
+ A light travels around the tab, in that session's colour. It rides the tab's own border box as a motion
138
+ path, so it covers the same distance in the same time on every edge and through the corners. (The obvious
139
+ way to do this - a conic gradient rotating behind a ring-shaped mask - turns at a constant angle instead,
140
+ which on a short wide tab crawls along the middle of an edge and then whips through the corners.)
141
+
142
+ The light lives in its own layer rather than in a pseudo-element on the tab, because it has to be clipped:
143
+ turning a corner swings part of it outside the tab, and the tab itself cannot clip (the drop markers a drag
144
+ draws sit just outside it, and would be cut off).
145
+ */
146
+ .tab .trace {
147
+ position: absolute;
148
+ inset: 0;
149
+ overflow: hidden;
150
+ border-radius: 7px 7px 0 0;
151
+ pointer-events: none;
152
+ /* Above the tab's own surface but under its label, so the light passes behind the name instead of over
153
+ it. This needs the tab to be a stacking context of its own (isolation, above), or a negative index
154
+ would drop the light behind the whole bar and out of sight. */
155
+ z-index: -1;
156
+ }
157
+
158
+ .tab .comet {
159
+ position: absolute;
160
+ width: 16px;
161
+ height: 2.5px;
162
+ border-radius: 2px;
163
+ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--sc) 45%, transparent) 55%, var(--sc));
164
+ filter: drop-shadow(0 0 4px var(--sc));
165
+ /* The leading edge sits on the path and the body hangs inwards, so the light stays inside the tab. */
166
+ offset-path: border-box;
167
+ offset-rotate: auto;
168
+ offset-anchor: 50% 0%;
169
+ /* One lap. TRACE_MS in app/tabs.js must match: it hands each new light a negative delay from the wall clock,
170
+ so a bar rebuilt mid-lap carries on instead of jumping back to the start. */
171
+ animation: orbit 4s linear infinite;
172
+ }
173
+
174
+ @keyframes orbit {
175
+ from {
176
+ offset-distance: 0%;
177
+ }
178
+ to {
179
+ offset-distance: 100%;
180
+ }
181
+ }
182
+
183
+ /* Motion paths off a coord-box are recent CSS. Without them the light becomes a line crossing the bottom
184
+ edge - the same idea, one edge instead of four - rather than nothing at all. */
185
+ @supports not (offset-path: border-box) {
186
+ .tab .comet {
187
+ left: 0;
188
+ right: 0;
189
+ bottom: 0;
190
+ width: auto;
191
+ height: 2px;
192
+ border-radius: 0;
193
+ background: linear-gradient(90deg, transparent, var(--sc), transparent);
194
+ background-size: 55% 100%;
195
+ background-repeat: no-repeat;
196
+ animation: sweep 1.5s linear infinite;
197
+ }
198
+
199
+ @keyframes sweep {
200
+ from {
201
+ background-position: -60% 0;
202
+ }
203
+ to {
204
+ background-position: 160% 0;
205
+ }
206
+ }
207
+ }
208
+
209
+ /* ---- It finished, and nobody was watching ---- */
210
+ /*
211
+ The loudest thing the bar does, and the one it is all for: an agent that stopped needs you, and you are
212
+ somewhere else. It arrives with two flashes and a shove sideways - the bar never moves otherwise, so the
213
+ movement is what catches an eye that is not on it - and then holds lit in the session's colour until the
214
+ tab is visited. The held state is the part that matters: the arrival plays once and can be missed.
215
+ */
216
+ .tab.attention {
217
+ color: var(--text);
218
+ background: color-mix(in srgb, var(--sc) 26%, #10151c);
219
+ box-shadow:
220
+ 0 0 0 1px var(--sc),
221
+ 0 0 18px color-mix(in srgb, var(--sc) 55%, transparent);
222
+ }
223
+
224
+ /* ARRIVAL_MS in app/tabs.js must match the total (4 x 0.34s), and is fed in as a negative delay so a re-render
225
+ mid-flash neither restarts it nor cuts it short. */
226
+ .tab.attention.arriving {
227
+ animation:
228
+ alertFlash 0.34s ease-in-out 4 alternate,
229
+ alertNudge 0.36s ease-in-out 2;
230
+ }
231
+
232
+ @keyframes alertFlash {
233
+ from {
234
+ background: color-mix(in srgb, var(--sc) 26%, #10151c);
235
+ }
236
+ to {
237
+ background: color-mix(in srgb, var(--sc) 72%, #10151c);
238
+ color: #06090d;
239
+ }
240
+ }
241
+
242
+ @keyframes alertNudge {
243
+ 0%,
244
+ 100% {
245
+ transform: translateX(0);
246
+ }
247
+ 25% {
248
+ transform: translateX(-3px);
249
+ }
250
+ 75% {
251
+ transform: translateX(3px);
252
+ }
253
+ }
254
+
255
+ /* Someone who has asked for less movement still gets both states, told without it: the working light holds
256
+ still on the bottom edge, and the alert simply appears already lit. */
257
+ @media (prefers-reduced-motion: reduce) {
258
+ .tab .comet {
259
+ left: 0;
260
+ right: 0;
261
+ bottom: 0;
262
+ width: auto;
263
+ height: 2px;
264
+ border-radius: 0;
265
+ background: var(--sc);
266
+ offset-path: none;
267
+ animation: none;
268
+ }
269
+
270
+ .tab.attention.arriving {
271
+ animation: none;
272
+ }
273
+ }
274
+
275
+ .tab .age {
276
+ font-size: 11px;
277
+ opacity: 0.85;
278
+ }
279
+
280
+ /* Inline name editor: sits where the name span was, borrowing the tab's font and colour so renaming
281
+ happens in place rather than in a popup. The session colour underlines it to say "this is being edited". */
282
+ .tab .rename {
283
+ font: inherit;
284
+ color: var(--text);
285
+ background: transparent;
286
+ border: none;
287
+ border-bottom: 1px solid var(--sc);
288
+ outline: none;
289
+ padding: 0;
290
+ margin: 0;
291
+ width: 12ch;
292
+ }
293
+
294
+ /* Another browser window is driving this session. */
295
+ .tab .elsewhere {
296
+ font-size: 10.5px;
297
+ color: var(--muted);
298
+ border: 1px solid var(--border);
299
+ border-radius: 5px;
300
+ padding: 1px 4px;
301
+ }
302
+
303
+ /* This session runs somewhere other than the workspace root, and this is where. Only the tabs that are
304
+ somewhere else carry it, so a bar of ordinary sessions looks exactly as it did. A long path is cut rather
305
+ than allowed to push the tabs around; the tooltip has all of it. */
306
+ .tab .dir {
307
+ max-width: 16ch;
308
+ overflow: hidden;
309
+ text-overflow: ellipsis;
310
+ white-space: nowrap;
311
+ font-size: 10.5px;
312
+ color: var(--muted);
313
+ border: 1px solid var(--border);
314
+ border-radius: 5px;
315
+ padding: 1px 4px;
316
+ }
317
+
318
+ .tab .close {
319
+ display: flex;
320
+ align-items: center;
321
+ justify-content: center;
322
+ width: 16px;
323
+ height: 16px;
324
+ padding: 0;
325
+ border: none;
326
+ border-radius: 4px;
327
+ background: transparent;
328
+ color: var(--muted);
329
+ font-size: 13px;
330
+ line-height: 1;
331
+ cursor: pointer;
332
+ }
333
+
334
+ .tab .close:hover {
335
+ background: rgba(248, 81, 73, 0.15);
336
+ color: var(--bad);
337
+ }
338
+
339
+ /* Shaped like a session tab, so the bar reads as one row: it is the outline of the next tab. Dashed and
340
+ empty is what keeps it from looking like a session that is already there. Split in two - the button starts
341
+ the usual agent where the last chamba session did, the chevron opens the panel that asks for both. */
342
+ #newtab-group {
343
+ display: flex;
344
+ align-items: stretch;
345
+ margin: 0 0 0 6px;
346
+ position: relative;
347
+ top: 1px;
348
+ }
349
+
350
+ #newtab,
351
+ #newtab-more {
352
+ display: flex;
353
+ align-items: center;
354
+ padding: 7px 11px;
355
+ background: transparent;
356
+ color: var(--muted);
357
+ border: 1px dashed var(--border);
358
+ border-bottom: none;
359
+ font-size: 12.5px;
360
+ line-height: 1;
361
+ white-space: nowrap;
362
+ cursor: pointer;
363
+ }
364
+
365
+ #newtab {
366
+ border-radius: 8px 0 0 0;
367
+ border-right: none;
368
+ }
369
+
370
+ #newtab-more {
371
+ border-radius: 0 8px 0 0;
372
+ padding: 7px 7px;
373
+ justify-content: center;
374
+ }
375
+
376
+ #newtab:hover,
377
+ #newtab-more:hover {
378
+ color: var(--accent);
379
+ border-color: var(--accent);
380
+ }
381
+
382
+ #newtab:disabled,
383
+ #newtab-more:disabled {
384
+ opacity: 0.4;
385
+ cursor: not-allowed;
386
+ }
387
+
388
+ /* What the next session should be, dropped under the chevron. Solid where the outline above it is dashed: that
389
+ outline is the next tab waiting to exist, this is a thing to fill in. Right under the button it came from,
390
+ because it opens on the way to something routine and a trip to the middle of the screen is not that. */
391
+ #newpop {
392
+ position: fixed;
393
+ z-index: 60;
394
+ display: flex;
395
+ flex-direction: column;
396
+ width: 288px;
397
+ padding: 12px;
398
+ background: var(--panel);
399
+ border: 1px solid var(--border);
400
+ border-radius: 10px;
401
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.5);
402
+ }
403
+
404
+ .poplabel {
405
+ margin: 0 0 6px;
406
+ color: var(--muted);
407
+ font-size: 11px;
408
+ letter-spacing: 0.03em;
409
+ text-transform: uppercase;
410
+ }
411
+
412
+ .poplabel:not(:first-child) {
413
+ margin-top: 12px;
414
+ }
415
+
416
+ #newpop .path {
417
+ width: 100%;
418
+ padding: 7px 9px;
419
+ background: var(--bg);
420
+ color: var(--text);
421
+ border: 1px solid var(--border);
422
+ border-radius: 8px;
423
+ font: inherit;
424
+ font-size: 12.5px;
425
+ outline: none;
426
+ box-sizing: border-box;
427
+ }
428
+
429
+ #newpop .path:focus {
430
+ border-color: var(--ws);
431
+ }
432
+
433
+ /* Which agent the session runs, above the directory it runs in - the same one question, asked once. */
434
+ .agentrow {
435
+ display: flex;
436
+ flex-wrap: wrap;
437
+ gap: 6px;
438
+ }
439
+
440
+ .agentchoice {
441
+ padding: 6px 11px;
442
+ background: var(--bg);
443
+ color: var(--muted);
444
+ border: 1px solid var(--border);
445
+ border-radius: 8px;
446
+ font: inherit;
447
+ font-size: 12.5px;
448
+ cursor: pointer;
449
+ }
450
+
451
+ .agentchoice:hover {
452
+ color: var(--text);
453
+ }
454
+
455
+ .agentchoice.chosen {
456
+ color: var(--ws);
457
+ border-color: var(--ws);
458
+ }
459
+
460
+ .poprow {
461
+ display: flex;
462
+ gap: 8px;
463
+ justify-content: flex-end;
464
+ margin-top: 14px;
465
+ }
466
+
467
+ #barright {
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 10px;
471
+ margin: 0 0 6px auto;
472
+ padding-left: 12px;
473
+ color: var(--muted);
474
+ font-size: 11.5px;
475
+ white-space: nowrap;
476
+ }
477
+
478
+ /* The workspace name is the only thing on the right now, so it reads as a label rather than muted
479
+ metadata: full text colour, no dimming. */
480
+ #barright .ws {
481
+ color: var(--text);
482
+ }
483
+
484
+ /* ---- Terminal ---- */
485
+
486
+ /* The row between the session bar and the composer: the terminal, and the web pane beside it when there is
487
+ one. The terminal takes whatever the pane leaves, which is all of it until an agent publishes a page. */
488
+ #middle {
489
+ display: flex;
490
+ flex: 1;
491
+ min-height: 0;
492
+ }
493
+
494
+ #termwrap {
495
+ position: relative;
496
+ flex: 1;
497
+ min-width: 0;
498
+ min-height: 0;
499
+ /* Spacing via margin, not padding: FitAddon measures the wrapper height but does not subtract
500
+ its padding, so wrapper padding makes it propose one row too many and the last line clips.
501
+ Margin gives the same visual inset (page and terminal share a background) without breaking fit. */
502
+ margin: 8px 12px;
503
+ }
504
+
505
+ #term {
506
+ height: 100%;
507
+ background: var(--term-bg);
508
+ overflow: hidden;
509
+ }
510
+
511
+ /* ---- Transient status pill ---- */
512
+
513
+ /* Floats over the bottom of the terminal instead of sitting in the layout: it appears and disappears
514
+ constantly (every copy says so), and in the flow each one shifted everything under it. Click-through,
515
+ so it can never swallow a click meant for the terminal underneath. */
516
+ #note {
517
+ position: absolute;
518
+ left: 50%;
519
+ bottom: 12px;
520
+ transform: translateX(-50%);
521
+ max-width: 85%;
522
+ padding: 5px 13px;
523
+ background: rgba(22, 27, 34, 0.95);
524
+ border: 1px solid var(--border);
525
+ border-radius: 999px;
526
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5);
527
+ color: var(--muted);
528
+ font-size: 12px;
529
+ overflow: hidden;
530
+ text-overflow: ellipsis;
531
+ white-space: nowrap;
532
+ pointer-events: none;
533
+ }
534
+
535
+ #note.error {
536
+ color: var(--bad);
537
+ border-color: rgba(248, 81, 73, 0.5);
538
+ }
539
+
540
+ /* No pill at all when there is nothing to say. */
541
+ #note:empty {
542
+ display: none;
543
+ }
544
+
545
+ /* ---- The web pane ---- */
546
+
547
+ /* Both the pane and its handle are hidden until the attached session has a page, so a terminal is the whole
548
+ window until an agent has something to show. `hidden` alone would lose to the display below. */
549
+ #pane[hidden],
550
+ #grip[hidden] {
551
+ display: none;
552
+ }
553
+
554
+ /* The drag handle between the terminal and the pane. Its own element rather than a border, because a border
555
+ is 1px and a grip has to be catchable: this is the one control on the page that is aimed at rather than
556
+ clicked, so it is wide enough to hit without looking and its nub is long enough to see where to aim. */
557
+ #grip {
558
+ position: relative;
559
+ flex: none;
560
+ width: 12px;
561
+ background: var(--panel);
562
+ border-left: 1px solid var(--border);
563
+ border-right: 1px solid var(--border);
564
+ cursor: col-resize;
565
+ }
566
+
567
+ #grip::after {
568
+ content: "";
569
+ position: absolute;
570
+ top: 50%;
571
+ left: 2px;
572
+ width: 6px;
573
+ height: 64px;
574
+ margin-top: -32px;
575
+ border-radius: 3px;
576
+ background: #3a4250;
577
+ }
578
+
579
+ #grip:hover::after,
580
+ body.dragging #grip::after {
581
+ background: var(--accent);
582
+ }
583
+
584
+ /* While a drag is on, the pointer belongs to the grip: no text selects, and the cursor does not change as it
585
+ crosses the terminal or a page. */
586
+ body.dragging {
587
+ cursor: col-resize;
588
+ user-select: none;
589
+ }
590
+
591
+ /* The width is app/pane.js's, in a custom property so the two rules that must win - the collapsed spine and
592
+ the narrow-screen layout - still do. The value here is what shows before the first drag. */
593
+ #pane {
594
+ display: flex;
595
+ flex: none;
596
+ flex-direction: column;
597
+ width: var(--pane-width, 44%);
598
+ min-width: 0;
599
+ background: var(--bg);
600
+ }
601
+
602
+ /* The pagination bar: this session's pages as chips, the arrows for a bar too narrow to hold them, and where
603
+ in the history the open page sits. Drawn by the interface, outside the page's frame, so it always says the
604
+ truth about which page is open. */
605
+ #pagebar {
606
+ display: flex;
607
+ flex: none;
608
+ align-items: center;
609
+ gap: 6px;
610
+ padding: 7px 10px;
611
+ background: var(--panel);
612
+ border-bottom: 1px solid var(--border);
613
+ overflow: hidden;
614
+ }
615
+
616
+ .pane-nav {
617
+ flex: none;
618
+ padding: 1px 6px;
619
+ border: none;
620
+ border-radius: 6px;
621
+ background: none;
622
+ color: var(--muted);
623
+ font-size: 15px;
624
+ line-height: 1.2;
625
+ cursor: pointer;
626
+ }
627
+
628
+ .pane-nav:hover {
629
+ background: #10151c;
630
+ color: var(--text);
631
+ }
632
+
633
+ #chips {
634
+ display: flex;
635
+ gap: 6px;
636
+ min-width: 0;
637
+ overflow-x: auto;
638
+ scrollbar-width: none;
639
+ }
640
+
641
+ #chips::-webkit-scrollbar {
642
+ display: none;
643
+ }
644
+
645
+ .chip {
646
+ display: flex;
647
+ flex: none;
648
+ align-items: baseline;
649
+ gap: 6px;
650
+ padding: 3px 11px;
651
+ border: 1px solid var(--border);
652
+ border-radius: 14px;
653
+ background: #10151c;
654
+ color: var(--muted);
655
+ font-size: 12px;
656
+ white-space: nowrap;
657
+ cursor: pointer;
658
+ }
659
+
660
+ .chip .ct {
661
+ color: var(--text);
662
+ }
663
+
664
+ .chip:hover {
665
+ border-color: #3a4250;
666
+ }
667
+
668
+ .chip.sel {
669
+ border-color: var(--accent);
670
+ background: #0f1722;
671
+ }
672
+
673
+ .chip.sel .ct {
674
+ color: var(--accent);
675
+ }
676
+
677
+ .chip .time {
678
+ font-size: 10.5px;
679
+ }
680
+
681
+ /* A page nobody has opened yet. The same green as everything else that means "something happened here". */
682
+ .chip .new {
683
+ padding: 0 5px;
684
+ border-radius: 8px;
685
+ background: var(--accent);
686
+ color: #04140b;
687
+ font-size: 9.5px;
688
+ font-weight: 700;
689
+ }
690
+
691
+ #page-count {
692
+ flex: none;
693
+ margin-left: auto;
694
+ padding-left: 8px;
695
+ color: var(--muted);
696
+ font-size: 11.5px;
697
+ }
698
+
699
+ /* Out of the way, at the end of the bar: the pane is put away far more often than the chips are paged. */
700
+ #pane-collapse {
701
+ display: flex;
702
+ align-items: center;
703
+ justify-content: center;
704
+ margin-left: 4px;
705
+ padding: 2px 5px;
706
+ }
707
+
708
+ #page-doc {
709
+ flex: 1;
710
+ min-width: 0;
711
+ overflow: auto;
712
+ }
713
+
714
+ /* The page itself: an agent's document, in a frame with an opaque origin (see app/pane-frame.js). Nothing
715
+ here styles its contents - the page brings its own. */
716
+ .page-frame {
717
+ display: block;
718
+ width: 100%;
719
+ height: 100%;
720
+ border: none;
721
+ background: var(--bg);
722
+ }
723
+
724
+ /* A page is a document with an event loop of its own, and a pointer that crosses into it during a drag is a
725
+ pointer the grip has to keep. Capture does that in every engine that has it; taking the frame out of the
726
+ hit test for the length of the drag is what makes it true everywhere. */
727
+ body.dragging .page-frame {
728
+ pointer-events: none;
729
+ }
730
+
731
+ .pane-empty {
732
+ margin: 0;
733
+ padding: 20px 24px;
734
+ color: var(--muted);
735
+ font-size: 13px;
736
+ line-height: 1.6;
737
+ }
738
+
739
+ /* What a session with nothing in its pane says when it is opened. The one place the feature introduces
740
+ itself, so it is a short page rather than a line: what the pane is for, what to ask for, and that a page
741
+ can ask back. */
742
+ .pane-intro {
743
+ padding: 18px 22px;
744
+ color: #9fb0c0;
745
+ font-size: 13px;
746
+ line-height: 1.6;
747
+ }
748
+
749
+ .pane-intro h2 {
750
+ margin: 0 0 8px;
751
+ color: var(--muted);
752
+ font-size: 13px;
753
+ font-weight: 600;
754
+ letter-spacing: 0.04em;
755
+ }
756
+
757
+ .pane-intro p {
758
+ margin: 0 0 10px;
759
+ }
760
+
761
+ /* The asks are things to say out loud, so they are set as the words themselves - no bullets in the way. */
762
+ .pane-intro ul {
763
+ margin: 0 0 12px;
764
+ padding: 0;
765
+ list-style: none;
766
+ color: var(--text);
767
+ font-size: 12.5px;
768
+ line-height: 1.9;
769
+ }
770
+
771
+ /* Collapsed: the pane is a spine with the unread count on it, and a click brings it back. */
772
+ #pane.collapsed {
773
+ width: 30px;
774
+ }
775
+
776
+ #pane.collapsed #pagebar,
777
+ #pane.collapsed #page-doc {
778
+ display: none;
779
+ }
780
+
781
+ #spine {
782
+ display: none;
783
+ flex: 1;
784
+ align-items: center;
785
+ justify-content: center;
786
+ padding: 0;
787
+ border: none;
788
+ border-left: 1px solid var(--border);
789
+ background: var(--panel);
790
+ cursor: pointer;
791
+ }
792
+
793
+ #pane.collapsed #spine {
794
+ display: flex;
795
+ }
796
+
797
+ #spine .label {
798
+ display: flex;
799
+ align-items: center;
800
+ gap: 8px;
801
+ color: var(--muted);
802
+ font-size: 11px;
803
+ letter-spacing: 0.18em;
804
+ text-transform: uppercase;
805
+ writing-mode: vertical-rl;
806
+ }
807
+
808
+ #spine:hover .label {
809
+ color: var(--text);
810
+ }
811
+
812
+ #spine-badge {
813
+ padding: 0 5px;
814
+ border-radius: 8px;
815
+ background: var(--accent);
816
+ color: #04140b;
817
+ font-size: 10px;
818
+ font-weight: 700;
819
+ writing-mode: horizontal-tb;
820
+ }
821
+
822
+ #spine-badge[hidden] {
823
+ display: none;
824
+ }
825
+
826
+ /* On a phone there is no room for a split: the pane takes the window, and the spine is the way back to the
827
+ terminal. Dragging is pointless at that width, so the grip goes. */
828
+ @media (max-width: 700px) {
829
+ #grip {
830
+ display: none;
831
+ }
832
+
833
+ #pane:not(.collapsed) {
834
+ width: 100%;
835
+ }
836
+ }
837
+
838
+ /* ---- Overlay cards (take over / end session / limit reached) ---- */
839
+
840
+ #overlay {
841
+ display: none;
842
+ position: absolute;
843
+ inset: 0;
844
+ background: rgba(13, 17, 23, 0.82);
845
+ align-items: center;
846
+ justify-content: center;
847
+ }
848
+
849
+ #overlay.show {
850
+ display: flex;
851
+ }
852
+
853
+ .card {
854
+ max-width: 460px;
855
+ padding: 18px 20px;
856
+ background: var(--panel);
857
+ border: 1px solid var(--border);
858
+ border-radius: 12px;
859
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
860
+ }
861
+
862
+ .card h2 {
863
+ margin: 0 0 8px;
864
+ font-size: 15px;
865
+ font-weight: 600;
866
+ }
867
+
868
+ .card p {
869
+ margin: 0 0 14px;
870
+ font-size: 13px;
871
+ line-height: 1.55;
872
+ color: var(--muted);
873
+ }
874
+
875
+ .card .row {
876
+ display: flex;
877
+ gap: 8px;
878
+ justify-content: flex-end;
879
+ }
880
+
881
+ .btn {
882
+ padding: 7px 13px;
883
+ background: rgba(48, 54, 61, 0.5);
884
+ color: var(--text);
885
+ border: 1px solid var(--border);
886
+ border-radius: 8px;
887
+ font-size: 12.5px;
888
+ cursor: pointer;
889
+ }
890
+
891
+ .btn:hover {
892
+ border-color: var(--accent);
893
+ color: var(--accent);
894
+ }
895
+
896
+ /* The one filled control on a card, in the workspace's colour with a near-black glyph on it - the only
897
+ place the hue is used as a fill rather than an edge, which is what makes it read as "this is the button". */
898
+ .btn.primary {
899
+ background: color-mix(in srgb, var(--ws) 82%, transparent);
900
+ color: #06090d;
901
+ border-color: var(--ws);
902
+ font-weight: 600;
903
+ }
904
+
905
+ .btn.primary:hover {
906
+ background: var(--ws);
907
+ color: #06090d;
908
+ }
909
+
910
+ .btn.danger {
911
+ background: rgba(248, 81, 73, 0.15);
912
+ color: #ff9b95;
913
+ border-color: rgba(248, 81, 73, 0.5);
914
+ }
915
+
916
+ .btn.danger:hover {
917
+ background: rgba(248, 81, 73, 0.28);
918
+ color: #fff;
919
+ border-color: var(--bad);
920
+ }
921
+
922
+ /* ---- The curtain ---- */
923
+
924
+ /* One state for "this window cannot do anything at all", over the whole page rather than over the terminal
925
+ like a card: while it is up, the tab bar and the composer are as dead as the terminal is, and covering
926
+ them says so. Half-transparent on purpose - the conversation underneath is still there, and seeing it
927
+ is what makes "nothing was lost" believable. */
928
+ #curtain {
929
+ display: none;
930
+ position: fixed;
931
+ inset: 0;
932
+ z-index: 20;
933
+ background: rgba(13, 17, 23, 0.78);
934
+ backdrop-filter: blur(2px);
935
+ align-items: center;
936
+ justify-content: center;
937
+ }
938
+
939
+ #curtain.show {
940
+ display: flex;
941
+ }
942
+
943
+ /* The dots on the one curtain that is waiting for something rather than reporting a state that has settled. They
944
+ go with the state: the curtain that replaces it has none, which is half of how it reads as finished. */
945
+ #curtain-card .dots {
946
+ display: inline-flex;
947
+ gap: 3px;
948
+ margin-left: 6px;
949
+ }
950
+
951
+ #curtain-card .dots i {
952
+ width: 4px;
953
+ height: 4px;
954
+ border-radius: 50%;
955
+ background: var(--muted);
956
+ animation: curtainDot 1.2s ease-in-out infinite;
957
+ }
958
+
959
+ @keyframes curtainDot {
960
+ 0%,
961
+ 60%,
962
+ 100% {
963
+ opacity: 0.25;
964
+ }
965
+ 30% {
966
+ opacity: 1;
967
+ }
968
+ }
969
+
970
+ /* The moment the socket drops, before any curtain: a control whose answer goes nowhere must stop taking
971
+ clicks. The composer disables itself, so this is about the bar - its tabs, its X, its + New session. */
972
+ body.offline #tabbar {
973
+ pointer-events: none;
974
+ }
975
+
976
+ /* ---- Sound, and stopping the container ---- */
977
+
978
+ /* Both sit at the far right of the bar, away from the tabs: neither one is about a single session. */
979
+ #bellbtn,
980
+ #stopbtn {
981
+ display: flex;
982
+ align-items: center;
983
+ justify-content: center;
984
+ padding: 4px;
985
+ background: transparent;
986
+ color: var(--muted);
987
+ border: 1px solid transparent;
988
+ border-radius: 7px;
989
+ cursor: pointer;
990
+ }
991
+
992
+ #stopbtn:hover {
993
+ color: var(--bad);
994
+ border-color: rgba(248, 81, 73, 0.5);
995
+ }
996
+
997
+ /* The bell is harmless, so it lights up like an ordinary control rather than a warning - and it keeps a little
998
+ distance from the button that ends everything, which is the one thing it must never be mistaken for. */
999
+ #bellbtn {
1000
+ margin-right: 3px;
1001
+ }
1002
+
1003
+ #bellbtn:hover {
1004
+ color: var(--text);
1005
+ border-color: var(--border);
1006
+ }
1007
+
1008
+ /* Muted reads as off at a glance, so the slash through the bell is not the only thing carrying it. */
1009
+ #bellbtn.muted {
1010
+ opacity: 0.55;
1011
+ }
1012
+
1013
+ /* ---- The status strip ---- */
1014
+
1015
+ /* One line between the terminal and the composer, out of the terminal's way: the attached session's model,
1016
+ the context it is holding, what is left of its quota, and the version it runs. It wraps rather than
1017
+ scrolls, because a number half off the edge of the window is worse than a second row. */
1018
+ #strip {
1019
+ display: flex;
1020
+ flex: none;
1021
+ flex-wrap: wrap;
1022
+ gap: 10px;
1023
+ align-items: center;
1024
+ padding: 6px 14px;
1025
+ background: var(--panel);
1026
+ border-top: 1px solid var(--border);
1027
+ color: var(--muted);
1028
+ font-size: 12px;
1029
+ }
1030
+
1031
+ #strip[hidden] {
1032
+ display: none;
1033
+ }
1034
+
1035
+ .seg {
1036
+ display: flex;
1037
+ gap: 6px;
1038
+ align-items: center;
1039
+ white-space: nowrap;
1040
+ }
1041
+
1042
+ /* The one value in a segment that the eye should land on; everything beside it is context for it. */
1043
+ .seg .name {
1044
+ color: var(--text);
1045
+ font-weight: 600;
1046
+ }
1047
+
1048
+ .sep {
1049
+ color: #3a4250;
1050
+ }
1051
+
1052
+ .bar {
1053
+ width: 74px;
1054
+ height: 6px;
1055
+ overflow: hidden;
1056
+ border-radius: 3px;
1057
+ background: #262c36;
1058
+ }
1059
+
1060
+ .bar .fill {
1061
+ display: block;
1062
+ height: 100%;
1063
+ background: var(--accent);
1064
+ }
1065
+
1066
+ /* A meter that has moved far enough to be worth looking at, and one that is nearly out of room. */
1067
+ .bar.warn .fill {
1068
+ background: #d29922;
1069
+ }
1070
+
1071
+ .bar.low .fill {
1072
+ background: var(--bad);
1073
+ }
1074
+
1075
+ /* ---- Composer ---- */
1076
+
1077
+ #composer {
1078
+ background: var(--panel);
1079
+ padding: 6px 12px 12px;
1080
+ display: flex;
1081
+ flex-direction: column;
1082
+ }
1083
+
1084
+ #input-wrap {
1085
+ position: relative;
1086
+ }
1087
+
1088
+ #input {
1089
+ display: block;
1090
+ width: 100%;
1091
+ /* Tall enough by default to show the three stacked buttons (3 x 28px + 2 x 6px gap, plus 8px
1092
+ top and bottom insets = 112px), which also gives the box a comfortable typing height. */
1093
+ min-height: 112px;
1094
+ max-height: 200px;
1095
+ resize: none;
1096
+ background: #0d1117;
1097
+ color: var(--text);
1098
+ border: 1px solid var(--border);
1099
+ border-radius: 10px;
1100
+ /* Right padding reserves the button gutter so typed text wraps to the next line instead of
1101
+ sliding behind the buttons (button column is 28px wide at right: 8px, so ~44px clears it). */
1102
+ padding: 9px 44px 9px 12px;
1103
+ font:
1104
+ 14px / 1.5 ui-monospace,
1105
+ "SF Mono",
1106
+ Menlo,
1107
+ Consolas,
1108
+ monospace;
1109
+ outline: none;
1110
+ transition: border-color 0.15s ease;
1111
+ }
1112
+
1113
+ /* Border carries the state (no separate status dot): lit in the workspace's colour when you can type into the
1114
+ attached session, plain grey while this window is between sessions or reconnecting. A dropped connection is
1115
+ not a failure here - the session on the other side kept running - so it goes quiet rather than warning. */
1116
+ #input.online {
1117
+ border-color: var(--ws);
1118
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--ws) 25%, transparent);
1119
+ }
1120
+
1121
+ #input.waiting {
1122
+ border-color: var(--border);
1123
+ }
1124
+
1125
+ #input:disabled {
1126
+ cursor: not-allowed;
1127
+ color: var(--muted);
1128
+ }
1129
+
1130
+ /* Fainter than the muted text so the placeholder hint recedes and typed input stands out. */
1131
+ #input::placeholder {
1132
+ color: var(--muted);
1133
+ opacity: 0.5;
1134
+ }
1135
+
1136
+ /* Action cluster floats inside the textbox as a vertical column pinned to the right edge. The
1137
+ textarea's right padding keeps typed text out from behind it. */
1138
+ #composer-actions {
1139
+ position: absolute;
1140
+ right: 8px;
1141
+ bottom: 8px;
1142
+ display: flex;
1143
+ flex-direction: column;
1144
+ align-items: center;
1145
+ gap: 6px;
1146
+ }
1147
+
1148
+ /* Semi-transparent so they stay unobtrusive when floating over typed text; solidify on hover. The workspace's
1149
+ hue is in them from the start - a faint wash and a tinted edge - so the cluster belongs to the window it
1150
+ sits in instead of being three grey squares. */
1151
+ .icon-btn {
1152
+ display: inline-flex;
1153
+ align-items: center;
1154
+ justify-content: center;
1155
+ width: 28px;
1156
+ height: 28px;
1157
+ padding: 0;
1158
+ background: color-mix(in srgb, var(--ws) 12%, rgba(48, 54, 61, 0.5));
1159
+ color: color-mix(in srgb, var(--ws) 30%, var(--text));
1160
+ border: 1px solid color-mix(in srgb, var(--ws) 28%, rgba(48, 54, 61, 0.6));
1161
+ border-radius: 7px;
1162
+ cursor: pointer;
1163
+ backdrop-filter: blur(2px);
1164
+ }
1165
+
1166
+ .icon-btn:hover {
1167
+ background: color-mix(in srgb, var(--ws) 22%, rgba(48, 54, 61, 0.9));
1168
+ border-color: var(--ws);
1169
+ color: var(--ws);
1170
+ }
1171
+
1172
+ .icon-btn:disabled {
1173
+ opacity: 0.4;
1174
+ cursor: not-allowed;
1175
+ }
1176
+
1177
+ /* Send is the one filled button in the window: the hue solid, with a near-black arrow on it. */
1178
+ .icon-btn.primary {
1179
+ background: color-mix(in srgb, var(--ws) 78%, transparent);
1180
+ color: #06090d;
1181
+ border-color: var(--ws);
1182
+ }
1183
+
1184
+ .icon-btn.primary:hover {
1185
+ background: var(--ws);
1186
+ color: #06090d;
1187
+ }
1188
+
1189
+ #mic.recording {
1190
+ background: var(--bad);
1191
+ border-color: var(--bad);
1192
+ color: #fff;
1193
+ }