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.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -23
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1582
- package/dist/server.js +0 -1831
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
// tabs.js - the session bar, which is mission control for this container.
|
|
2
|
+
//
|
|
3
|
+
// Every live agent in the container is a tab, this window drives one of them at a time, and none of them ends
|
|
4
|
+
// because a window closed or a laptop slept. Tabs are dragged to reorder them, and the order is the registry's,
|
|
5
|
+
// so every window agrees on it. A tab also says what its session is doing without being opened: a light travels
|
|
6
|
+
// round it while the agent works, and it holds lit until visited when an agent finished something nobody was
|
|
7
|
+
// there to see.
|
|
8
|
+
//
|
|
9
|
+
// The bar is rebuilt from scratch on every frame the server sends and on the age tick, so nothing in it is
|
|
10
|
+
// updated in place and nothing outside it may keep a reference to a tab.
|
|
11
|
+
|
|
12
|
+
import { bellButton, forgetChime } from "./alerts.js";
|
|
13
|
+
import { closeCard, stopCard } from "./cards.js";
|
|
14
|
+
import { sendFrame } from "./connection.js";
|
|
15
|
+
import { SVG_NS, tabbar } from "./dom.js";
|
|
16
|
+
import { repositionNewPop, toggleNewPop } from "./new-session.js";
|
|
17
|
+
import { agents, attachedSid, defaultAgent, defaultCwd, maxSessions, sessions, workspaceName } from "./state.js";
|
|
18
|
+
import { focusTerminal, term } from "./terminal.js";
|
|
19
|
+
import { colorFor } from "./theme.js";
|
|
20
|
+
|
|
21
|
+
// The session tab being renamed right now (its id), and the text in its editor. Kept in module state so a
|
|
22
|
+
// bar re-render (an incoming server frame, or the 60s age tick) rebuilds the editor without losing what
|
|
23
|
+
// was typed. `renameJustStarted` selects the whole default label the first time, so it can be typed over.
|
|
24
|
+
let editingSid = null;
|
|
25
|
+
let editingValue = "";
|
|
26
|
+
let renameJustStarted = false;
|
|
27
|
+
// The session tab being dragged along the bar right now, and where it would land: `dropIndex` is the
|
|
28
|
+
// position it would hold in the list once it has been taken out of it, which is what the registry splices
|
|
29
|
+
// at. `barRenderPending` remembers a render that was skipped because a drag was in flight.
|
|
30
|
+
let draggingSid = null;
|
|
31
|
+
let dropIndex = null;
|
|
32
|
+
let barRenderPending = false;
|
|
33
|
+
|
|
34
|
+
// --- How old a session is ----------------------------------------------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
// How often the ages on the session tabs are redrawn. Age is what makes a session parked for a week
|
|
37
|
+
// obvious, so it has to keep up without being a per-second timer.
|
|
38
|
+
const AGE_TICK_MS = 60_000;
|
|
39
|
+
|
|
40
|
+
export function ageLabel(createdAt) {
|
|
41
|
+
const minutes = Math.floor((Date.now() - createdAt) / 60_000);
|
|
42
|
+
if (minutes < 1) return "now";
|
|
43
|
+
if (minutes < 60) return `${minutes}m`;
|
|
44
|
+
const hours = Math.floor(minutes / 60);
|
|
45
|
+
if (hours < 24) return `${hours}h`;
|
|
46
|
+
return `${Math.floor(hours / 24)}d`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Animations that outlive a render ----------------------------------------------------------------------------------------------------
|
|
50
|
+
//
|
|
51
|
+
// The bar is rebuilt from scratch on every frame the server sends, and both of the moving states last longer
|
|
52
|
+
// than the gap between frames. A CSS animation on a brand new element starts from zero, so left alone the
|
|
53
|
+
// travelling light would jump back to the start of its lap and the "it finished" flash would replay - or
|
|
54
|
+
// worse, be cut off half way and never seen. Both are fixed the same way: work out how far in the animation
|
|
55
|
+
// should already be and hand that to the browser as a negative delay, so a fresh element carries on mid-stride.
|
|
56
|
+
|
|
57
|
+
// One lap of the travelling light, and how long the arrival flash lasts. Both must match styles.css.
|
|
58
|
+
const TRACE_MS = 4_000;
|
|
59
|
+
const ARRIVAL_MS = 1_400;
|
|
60
|
+
|
|
61
|
+
// The travelling light, in its own clipped layer: the light rides the layer's edge and turns the corners, so
|
|
62
|
+
// the part of it that hangs past a corner has to be cut off rather than drawn over the neighbouring tab.
|
|
63
|
+
// Every light in the bar shares one phase (the wall clock), so several working sessions move as one.
|
|
64
|
+
function traceLayer() {
|
|
65
|
+
const layer = document.createElement("span");
|
|
66
|
+
layer.className = "trace";
|
|
67
|
+
const comet = document.createElement("i");
|
|
68
|
+
comet.className = "comet";
|
|
69
|
+
comet.style.animationDelay = `-${Date.now() % TRACE_MS}ms`;
|
|
70
|
+
layer.append(comet);
|
|
71
|
+
return layer;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// When each waiting session's alert first appeared, so the tab flash plays once per alert rather than once per
|
|
75
|
+
// frame. Stamped from the incoming frame rather than while the bar is drawn, because a bar render is skipped
|
|
76
|
+
// mid-drag and an alert that arrives then must still be able to flash. The chime reads the same stamps: an
|
|
77
|
+
// alert that is still standing a few seconds after it appeared is one nobody has come back to.
|
|
78
|
+
export const arrivedAt = new Map();
|
|
79
|
+
|
|
80
|
+
export function noteArrivals() {
|
|
81
|
+
for (const entry of sessions) {
|
|
82
|
+
if (entry.attention && !arrivedAt.has(entry.id)) arrivedAt.set(entry.id, Date.now());
|
|
83
|
+
}
|
|
84
|
+
// A session that is no longer waiting (visited, or gone from the bar) may flash again next time it finishes.
|
|
85
|
+
for (const sid of [...arrivedAt.keys()]) {
|
|
86
|
+
if (sessions.some((entry) => entry.id === sid && entry.attention)) continue;
|
|
87
|
+
arrivedAt.delete(sid);
|
|
88
|
+
// And sound again: the next finish is a new alert, not the one that was just spent.
|
|
89
|
+
forgetChime(sid);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function startArrival(tab, sid) {
|
|
94
|
+
const elapsed = Date.now() - (arrivedAt.get(sid) ?? Date.now());
|
|
95
|
+
// Long past: the tab just holds its lit state, which is the part that waits for you.
|
|
96
|
+
if (elapsed >= ARRIVAL_MS) return;
|
|
97
|
+
tab.classList.add("arriving");
|
|
98
|
+
tab.style.animationDelay = `-${elapsed}ms`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- One tab -----------------------------------------------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
// The tab's name is either a static label or, while this tab is being renamed, an inline editor.
|
|
104
|
+
|
|
105
|
+
// Static: the custom name if one is set, else the default "agent N". Double-click turns it into the
|
|
106
|
+
// editor; a single click still selects the tab, so renaming never gets in the way of switching.
|
|
107
|
+
function staticName(entry) {
|
|
108
|
+
const name = document.createElement("span");
|
|
109
|
+
name.className = "name";
|
|
110
|
+
name.textContent = entry.name || entry.label;
|
|
111
|
+
name.addEventListener("dblclick", (event) => {
|
|
112
|
+
event.stopPropagation();
|
|
113
|
+
startRename(entry);
|
|
114
|
+
});
|
|
115
|
+
return name;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Inline editor: seeded from editingValue so a mid-edit re-render keeps the text. Enter commits, Escape
|
|
119
|
+
// cancels, clicking away commits. Clicks inside must not bubble to the tab, which would switch session.
|
|
120
|
+
function renameEditor(entry) {
|
|
121
|
+
const editor = document.createElement("input");
|
|
122
|
+
editor.type = "text";
|
|
123
|
+
editor.className = "rename";
|
|
124
|
+
editor.value = editingValue;
|
|
125
|
+
editor.spellcheck = false;
|
|
126
|
+
// A soft cap for the field; the server trims to its own limit and strips control characters.
|
|
127
|
+
editor.maxLength = 60;
|
|
128
|
+
editor.addEventListener("click", (event) => event.stopPropagation());
|
|
129
|
+
editor.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
130
|
+
editor.addEventListener("input", () => {
|
|
131
|
+
editingValue = editor.value;
|
|
132
|
+
});
|
|
133
|
+
editor.addEventListener("keydown", (event) => {
|
|
134
|
+
if (event.key === "Enter") {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
commitRename(entry.id);
|
|
137
|
+
} else if (event.key === "Escape") {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
cancelRename();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
// Enter and Escape clear editingSid before the re-render blurs this field, so this does not double up.
|
|
143
|
+
editor.addEventListener("blur", () => {
|
|
144
|
+
if (editingSid === entry.id) commitRename(entry.id);
|
|
145
|
+
});
|
|
146
|
+
return editor;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startRename(entry) {
|
|
150
|
+
editingSid = entry.id;
|
|
151
|
+
editingValue = entry.name || entry.label;
|
|
152
|
+
renameJustStarted = true;
|
|
153
|
+
renderBar();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function commitRename(sid) {
|
|
157
|
+
const name = editingValue.trim();
|
|
158
|
+
editingSid = null;
|
|
159
|
+
editingValue = "";
|
|
160
|
+
// Sent even when empty: the server reads that as "clear the name" and the tab falls back to its default.
|
|
161
|
+
sendFrame({ t: "rename", sid, name });
|
|
162
|
+
renderBar();
|
|
163
|
+
term.focus();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function cancelRename() {
|
|
167
|
+
editingSid = null;
|
|
168
|
+
editingValue = "";
|
|
169
|
+
renderBar();
|
|
170
|
+
term.focus();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// What a tab is saying about its session right now, for the tooltip. The colours say it at a glance; this is
|
|
174
|
+
// for the moment someone wonders what the light means.
|
|
175
|
+
function stateNote(entry) {
|
|
176
|
+
if (entry.working) return " - working";
|
|
177
|
+
if (entry.attention) return " - finished, and not looked at since";
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// How a directory reads in a sentence. The root has no path worth printing, so it gets a name.
|
|
182
|
+
function whereLabel(cwd) {
|
|
183
|
+
return cwd === "" ? "the workspace root" : cwd;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Where the session runs, when that is worth saying: sessions in the workspace root (the usual case) show
|
|
187
|
+
// nothing, so the chip means "this one is somewhere else" without every tab carrying a path.
|
|
188
|
+
function dirChip(entry) {
|
|
189
|
+
const chip = document.createElement("span");
|
|
190
|
+
chip.className = "dir";
|
|
191
|
+
chip.textContent = entry.cwd;
|
|
192
|
+
return chip;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function tabFor(entry) {
|
|
196
|
+
const tab = document.createElement("div");
|
|
197
|
+
tab.className = "tab";
|
|
198
|
+
if (entry.id === attachedSid) tab.classList.add("active");
|
|
199
|
+
if (entry.unread) tab.classList.add("unread");
|
|
200
|
+
if (entry.working) tab.classList.add("working");
|
|
201
|
+
if (entry.attention) tab.classList.add("attention");
|
|
202
|
+
tab.style.setProperty("--sc", colorFor(entry));
|
|
203
|
+
// The tooltip keeps the default "agent N" even when a custom name is shown, so the number stays findable.
|
|
204
|
+
const where = entry.cwd ? ` in ${entry.cwd}` : "";
|
|
205
|
+
tab.title = `${entry.label}${where} - running ${ageLabel(entry.createdAt)}${stateNote(entry)} - double-click the name to rename`;
|
|
206
|
+
|
|
207
|
+
if (entry.working) tab.append(traceLayer());
|
|
208
|
+
if (entry.attention) startArrival(tab, entry.id);
|
|
209
|
+
|
|
210
|
+
const dot = document.createElement("span");
|
|
211
|
+
dot.className = "dot";
|
|
212
|
+
const name = entry.id === editingSid ? renameEditor(entry) : staticName(entry);
|
|
213
|
+
const age = document.createElement("span");
|
|
214
|
+
age.className = "age";
|
|
215
|
+
age.textContent = ageLabel(entry.createdAt);
|
|
216
|
+
tab.append(dot, name, age);
|
|
217
|
+
if (entry.cwd) tab.append(dirChip(entry));
|
|
218
|
+
|
|
219
|
+
// Another window is driving this one, so a takeover prompt on click is not a surprise.
|
|
220
|
+
if (entry.attached && entry.id !== attachedSid) {
|
|
221
|
+
const chip = document.createElement("span");
|
|
222
|
+
chip.className = "elsewhere";
|
|
223
|
+
chip.textContent = "in another window";
|
|
224
|
+
tab.append(chip);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const close = document.createElement("button");
|
|
228
|
+
close.type = "button";
|
|
229
|
+
close.className = "close";
|
|
230
|
+
close.textContent = "×";
|
|
231
|
+
close.title = "End this session";
|
|
232
|
+
close.addEventListener("click", (event) => {
|
|
233
|
+
// Without this the click also selects the tab, which is the last thing you want next to a kill.
|
|
234
|
+
event.stopPropagation();
|
|
235
|
+
closeCard(entry);
|
|
236
|
+
});
|
|
237
|
+
tab.append(close);
|
|
238
|
+
|
|
239
|
+
tab.addEventListener("click", () => {
|
|
240
|
+
// The click itself blurred the terminal, so the keyboard goes back to it either way - switching
|
|
241
|
+
// sessions and clicking the session you are already in both leave you able to type and paste.
|
|
242
|
+
focusTerminal();
|
|
243
|
+
if (entry.id === attachedSid) return;
|
|
244
|
+
sendFrame({ t: "attach", sid: entry.id });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
makeDraggable(tab, entry);
|
|
248
|
+
return tab;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Reordering the bar ------------------------------------------------------------------------------------------------------------------
|
|
252
|
+
//
|
|
253
|
+
// The order of the tabs belongs to the registry, so a drag ends as one "reorder" frame and the new order
|
|
254
|
+
// arrives back as an ordinary bar broadcast - every window moves together, and nothing is sorted locally on
|
|
255
|
+
// the way. Dragging is mouse and trackpad only: this is the browser's own drag and drop, which touch does
|
|
256
|
+
// not fire.
|
|
257
|
+
|
|
258
|
+
// Where the dragged session would land if it were dropped on `entry`. Counted in the list with the dragged
|
|
259
|
+
// session already taken out, so it is the same index the registry splices at. Null over the dragged tab
|
|
260
|
+
// itself, which is not a move.
|
|
261
|
+
function dropIndexFor(entry, after) {
|
|
262
|
+
const others = sessions.filter((session) => session.id !== draggingSid);
|
|
263
|
+
const at = others.findIndex((session) => session.id === entry.id);
|
|
264
|
+
if (at === -1) return null;
|
|
265
|
+
return after ? at + 1 : at;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function clearDropMarks() {
|
|
269
|
+
for (const el of tabbar.querySelectorAll(".tab")) el.classList.remove("drop-before", "drop-after");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The line showing where the tab would go, drawn in the gap on one side of a tab.
|
|
273
|
+
function markDrop(tab, after) {
|
|
274
|
+
clearDropMarks();
|
|
275
|
+
tab.classList.add(after ? "drop-after" : "drop-before");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function makeDraggable(tab, entry) {
|
|
279
|
+
// A tab being renamed is not draggable: it holds a text field, and a draggable ancestor stops the
|
|
280
|
+
// pointer from selecting the text inside it.
|
|
281
|
+
tab.draggable = entry.id !== editingSid;
|
|
282
|
+
|
|
283
|
+
tab.addEventListener("dragstart", (event) => {
|
|
284
|
+
draggingSid = entry.id;
|
|
285
|
+
dropIndex = null;
|
|
286
|
+
if (event.dataTransfer) {
|
|
287
|
+
event.dataTransfer.effectAllowed = "move";
|
|
288
|
+
// Firefox starts no drag at all without a payload. It is never read back: the dragged session
|
|
289
|
+
// is the one in draggingSid, and only a drag started in this bar is honoured.
|
|
290
|
+
event.dataTransfer.setData("text/plain", entry.id);
|
|
291
|
+
}
|
|
292
|
+
tab.classList.add("dragging");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
tab.addEventListener("dragover", (event) => {
|
|
296
|
+
if (!draggingSid) return;
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
const box = tab.getBoundingClientRect();
|
|
299
|
+
const after = event.clientX > box.left + box.width / 2;
|
|
300
|
+
dropIndex = dropIndexFor(entry, after);
|
|
301
|
+
if (dropIndex === null) clearDropMarks();
|
|
302
|
+
else markDrop(tab, after);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
tab.addEventListener("drop", (event) => {
|
|
306
|
+
if (!draggingSid) return;
|
|
307
|
+
event.preventDefault();
|
|
308
|
+
commitDrag();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
tab.addEventListener("dragend", endDrag);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The bar past the last tab - the empty space and the "+ New session" button - means "put it at the end".
|
|
315
|
+
// The bar reads as one row, so a drop anywhere along it should land rather than being thrown away.
|
|
316
|
+
tabbar.addEventListener("dragover", (event) => {
|
|
317
|
+
if (!draggingSid) return;
|
|
318
|
+
// A tab does its own half-and-half hit test; this is only the space around them.
|
|
319
|
+
if (event.target instanceof Element && event.target.closest(".tab")) return;
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
const tabs = tabbar.querySelectorAll(".tab");
|
|
322
|
+
const last = tabs[tabs.length - 1];
|
|
323
|
+
dropIndex = Math.max(0, sessions.length - 1);
|
|
324
|
+
if (last) markDrop(last, true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
tabbar.addEventListener("drop", (event) => {
|
|
328
|
+
// A drop on a tab has already been handled and cleared the drag, so this only catches the space around
|
|
329
|
+
// them.
|
|
330
|
+
if (!draggingSid) return;
|
|
331
|
+
event.preventDefault();
|
|
332
|
+
commitDrag();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
function commitDrag() {
|
|
336
|
+
if (draggingSid && dropIndex !== null) sendFrame({ t: "reorder", sid: draggingSid, index: dropIndex });
|
|
337
|
+
endDrag();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Every drag ends here, dropped or abandoned, so none can leave the bar marked up or frozen mid-render.
|
|
341
|
+
function endDrag() {
|
|
342
|
+
draggingSid = null;
|
|
343
|
+
dropIndex = null;
|
|
344
|
+
clearDropMarks();
|
|
345
|
+
tabbar.querySelector(".tab.dragging")?.classList.remove("dragging");
|
|
346
|
+
if (barRenderPending) {
|
|
347
|
+
barRenderPending = false;
|
|
348
|
+
renderBar();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- The two drawn buttons ---------------------------------------------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
// Ending the day: one button, and it always asks first. Drawn rather than written, because the bar is
|
|
355
|
+
// tabs and this is not one of them - and drawn here rather than shipped in the page, since the bar is
|
|
356
|
+
// rebuilt from scratch on every frame.
|
|
357
|
+
function stopButton() {
|
|
358
|
+
const button = document.createElement("button");
|
|
359
|
+
button.id = "stopbtn";
|
|
360
|
+
button.type = "button";
|
|
361
|
+
button.title = "Stop the container - ends every session in it";
|
|
362
|
+
button.setAttribute("aria-label", "Stop the container");
|
|
363
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
364
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
365
|
+
svg.setAttribute("width", "14");
|
|
366
|
+
svg.setAttribute("height", "14");
|
|
367
|
+
svg.setAttribute("fill", "none");
|
|
368
|
+
svg.setAttribute("stroke", "currentColor");
|
|
369
|
+
svg.setAttribute("stroke-width", "2");
|
|
370
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
371
|
+
const arc = document.createElementNS(SVG_NS, "path");
|
|
372
|
+
arc.setAttribute("d", "M18.36 6.64a9 9 0 1 1-12.73 0");
|
|
373
|
+
const stem = document.createElementNS(SVG_NS, "line");
|
|
374
|
+
stem.setAttribute("x1", "12");
|
|
375
|
+
stem.setAttribute("y1", "2");
|
|
376
|
+
stem.setAttribute("x2", "12");
|
|
377
|
+
stem.setAttribute("y2", "12");
|
|
378
|
+
svg.append(arc, stem);
|
|
379
|
+
button.append(svg);
|
|
380
|
+
button.addEventListener("click", stopCard);
|
|
381
|
+
return button;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function chevronIcon() {
|
|
385
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
386
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
387
|
+
svg.setAttribute("width", "13");
|
|
388
|
+
svg.setAttribute("height", "13");
|
|
389
|
+
svg.setAttribute("fill", "none");
|
|
390
|
+
svg.setAttribute("stroke", "currentColor");
|
|
391
|
+
svg.setAttribute("stroke-width", "2");
|
|
392
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
393
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
394
|
+
const down = document.createElementNS(SVG_NS, "path");
|
|
395
|
+
down.setAttribute("d", "M6 9l6 6 6-6");
|
|
396
|
+
svg.append(down);
|
|
397
|
+
return svg;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- The bar -----------------------------------------------------------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
export function renderBar() {
|
|
403
|
+
// A drag is in flight, so the bar holds still: rebuilding it would replace the element being dragged,
|
|
404
|
+
// which cancels the drag outright. Both the age tick and any incoming frame can land mid-drag. The bar
|
|
405
|
+
// catches up the moment the drag ends.
|
|
406
|
+
if (draggingSid) {
|
|
407
|
+
barRenderPending = true;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
tabbar.textContent = "";
|
|
411
|
+
for (const entry of sessions) {
|
|
412
|
+
tabbar.append(tabFor(entry));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A split button: the left half starts a session the usual way in one click, and the chevron opens the
|
|
416
|
+
// panel that asks which agent and which directory. Neither moves the default - the panel starts this one
|
|
417
|
+
// session differently.
|
|
418
|
+
const group = document.createElement("div");
|
|
419
|
+
group.id = "newtab-group";
|
|
420
|
+
const atCap = sessions.length >= maxSessions;
|
|
421
|
+
|
|
422
|
+
const add = document.createElement("button");
|
|
423
|
+
add.id = "newtab";
|
|
424
|
+
add.type = "button";
|
|
425
|
+
add.textContent = "+ New session";
|
|
426
|
+
add.title = `Start another ${defaultAgent} in this container, in ${whereLabel(defaultCwd)}`;
|
|
427
|
+
add.disabled = atCap;
|
|
428
|
+
add.addEventListener("click", () => sendFrame({ t: "new" }));
|
|
429
|
+
|
|
430
|
+
const more = document.createElement("button");
|
|
431
|
+
more.id = "newtab-more";
|
|
432
|
+
more.type = "button";
|
|
433
|
+
more.append(chevronIcon());
|
|
434
|
+
more.title = agents.length > 1 ? "Start a session with another agent or in another directory" : "Start a session in another directory";
|
|
435
|
+
more.setAttribute("aria-label", more.title);
|
|
436
|
+
more.setAttribute("aria-haspopup", "dialog");
|
|
437
|
+
more.disabled = atCap;
|
|
438
|
+
more.addEventListener("click", toggleNewPop);
|
|
439
|
+
|
|
440
|
+
group.append(add, more);
|
|
441
|
+
tabbar.append(group);
|
|
442
|
+
|
|
443
|
+
// The far right of the bar: which container this is, and the way to end it. The session count that used
|
|
444
|
+
// to live here was redundant - the tabs show how many there are, and "+ New session" disables at the
|
|
445
|
+
// limit - so the name and the power button get the space.
|
|
446
|
+
const right = document.createElement("div");
|
|
447
|
+
right.id = "barright";
|
|
448
|
+
if (workspaceName) {
|
|
449
|
+
const ws = document.createElement("span");
|
|
450
|
+
ws.className = "ws";
|
|
451
|
+
ws.textContent = workspaceName;
|
|
452
|
+
right.append(ws);
|
|
453
|
+
}
|
|
454
|
+
right.append(bellButton(), stopButton());
|
|
455
|
+
tabbar.append(right);
|
|
456
|
+
|
|
457
|
+
// The panel hangs off the body, so it survives this rebuild - but the button under it just moved.
|
|
458
|
+
repositionNewPop();
|
|
459
|
+
|
|
460
|
+
// Keep focus in the rename editor across re-renders. The first render after a double-click selects the
|
|
461
|
+
// whole label so it can be typed over; a later render (an incoming frame) only restores the caret.
|
|
462
|
+
const editor = tabbar.querySelector("input.rename");
|
|
463
|
+
if (editor) {
|
|
464
|
+
editor.focus();
|
|
465
|
+
if (renameJustStarted) {
|
|
466
|
+
editor.select();
|
|
467
|
+
renameJustStarted = false;
|
|
468
|
+
} else {
|
|
469
|
+
const end = editor.value.length;
|
|
470
|
+
editor.setSelectionRange(end, end);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
setInterval(renderBar, AGE_TICK_MS);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// terminal.js - the terminal itself: the attached session's live TUI, and the keystrokes that go back.
|
|
2
|
+
//
|
|
3
|
+
// xterm.js and its fit addon come from the page as globals, since they are plain scripts under /vendor. A
|
|
4
|
+
// module script is deferred, so both are on window by the time this runs.
|
|
5
|
+
|
|
6
|
+
import { connected, isCurtainUp, sendFrame } from "./connection.js";
|
|
7
|
+
import { input, overlay, termEl } from "./dom.js";
|
|
8
|
+
import { attachedSid } from "./state.js";
|
|
9
|
+
|
|
10
|
+
export const term = new Terminal({
|
|
11
|
+
cursorBlink: true,
|
|
12
|
+
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
|
13
|
+
fontSize: 13,
|
|
14
|
+
// A touch lighter than pure black, matching --term-bg in the stylesheet.
|
|
15
|
+
theme: { background: "#12161d" },
|
|
16
|
+
// An agent that turns on mouse reporting takes the mouse away from selection entirely. Alt+drag is
|
|
17
|
+
// then the way to select anyway, so text stays copyable whatever the TUI does with the mouse.
|
|
18
|
+
macOptionClickForcesSelection: true,
|
|
19
|
+
});
|
|
20
|
+
const fit = new FitAddon.FitAddon();
|
|
21
|
+
term.loadAddon(fit);
|
|
22
|
+
|
|
23
|
+
// Put the terminal on the page and size it. Called from the entry rather than here, because a mounted
|
|
24
|
+
// terminal is the first step of a page that is being started rather than a side effect of loading a module.
|
|
25
|
+
export function mountTerminal() {
|
|
26
|
+
term.open(termEl);
|
|
27
|
+
doFit();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Fit the terminal to its container and push the size to the PTY. Called on load, after fonts
|
|
31
|
+
// settle, and on every (debounced) resize. Fitting only once too early leaves the last row
|
|
32
|
+
// clipped because the monospace metrics are not final yet.
|
|
33
|
+
export function doFit() {
|
|
34
|
+
try {
|
|
35
|
+
fit.fit();
|
|
36
|
+
sendResize();
|
|
37
|
+
} catch {
|
|
38
|
+
// A zero-size layout pass can throw; the next fit settles it.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Re-fit after the web font and initial layout settle, which is when the early clipping cleared up.
|
|
43
|
+
window.addEventListener("load", doFit);
|
|
44
|
+
if (document.fonts?.ready) document.fonts.ready.then(doFit);
|
|
45
|
+
setTimeout(doFit, 250);
|
|
46
|
+
|
|
47
|
+
export function sendResize() {
|
|
48
|
+
if (connected && attachedSid) sendFrame({ t: "resize", cols: term.cols, rows: term.rows });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Force a full repaint shortly after output settles. The DOM renderer can leave stale cells behind
|
|
52
|
+
// when a line is redrawn with wide glyphs (an agent drawing a spinner, or an emoji), showing as gaps or
|
|
53
|
+
// doubled text until something - like selecting the text - forces those cells to repaint. A debounced full
|
|
54
|
+
// refresh does the same thing proactively. Debounced so streaming output stays cheap.
|
|
55
|
+
let refreshTimer = null;
|
|
56
|
+
export function scheduleRefresh() {
|
|
57
|
+
if (refreshTimer) return;
|
|
58
|
+
refreshTimer = setTimeout(() => {
|
|
59
|
+
refreshTimer = null;
|
|
60
|
+
try {
|
|
61
|
+
term.refresh(0, term.rows - 1);
|
|
62
|
+
} catch {
|
|
63
|
+
// Refresh is best-effort; a transient bad state must not break output.
|
|
64
|
+
}
|
|
65
|
+
}, 50);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Hand the keyboard back to the terminal. Clicking in the session bar blurs it - a tab is a plain div, so
|
|
69
|
+
// the click moves focus off the terminal, and "+ New session" is a button that takes focus itself - and
|
|
70
|
+
// while it is blurred both typing and Cmd+V have nowhere to land, which reads as the terminal ignoring the
|
|
71
|
+
// clipboard. Anything the user is deliberately typing in keeps focus: the composer, a rename editor, and
|
|
72
|
+
// the buttons on an open card.
|
|
73
|
+
export function focusTerminal() {
|
|
74
|
+
const active = document.activeElement;
|
|
75
|
+
if (active === input || active?.classList.contains("rename")) return;
|
|
76
|
+
if (overlay.classList.contains("show")) return;
|
|
77
|
+
// Nothing types into a page that cannot answer.
|
|
78
|
+
if (isCurtainUp()) return;
|
|
79
|
+
term.focus();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Raw keystrokes typed directly into the terminal go straight to the attached session's PTY.
|
|
83
|
+
term.onData((data) => {
|
|
84
|
+
if (connected && attachedSid) sendFrame({ t: "in", data });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Keep the PTY's window size in step with the rendered terminal. Debounced: a resize storm
|
|
88
|
+
// (layout settling, pane drag) collapses to one fit + one resize, which cuts down the redraw
|
|
89
|
+
// artifacts the TUI shows when width changes mid-render.
|
|
90
|
+
let resizeTimer = null;
|
|
91
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
92
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
93
|
+
resizeTimer = setTimeout(() => {
|
|
94
|
+
try {
|
|
95
|
+
fit.fit();
|
|
96
|
+
sendResize();
|
|
97
|
+
} catch {
|
|
98
|
+
// Layout can fire a zero-size pass; ignore and let the next one settle.
|
|
99
|
+
}
|
|
100
|
+
}, 80);
|
|
101
|
+
});
|
|
102
|
+
resizeObserver.observe(termEl);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// theme.js - one palette, used for two different jobs.
|
|
2
|
+
//
|
|
3
|
+
// The workspace takes one slot of it and keeps it: the composer border, the active tab's edge, the buttons
|
|
4
|
+
// and the frame around the tab icon are all that one colour, so a window is recognisable across a screen full
|
|
5
|
+
// of them before a single word is read. The slot comes from the published port, which chamba assigns per workspace,
|
|
6
|
+
// so two containers open side by side are almost never the same colour and nothing has to be configured.
|
|
7
|
+
//
|
|
8
|
+
// The sessions inside that window then rotate through the slots the workspace did not take. That is what
|
|
9
|
+
// makes a tab's own dot mean "this session" rather than "this workspace", and it is why a dot can never come
|
|
10
|
+
// out the same colour as the window it lives in.
|
|
11
|
+
|
|
12
|
+
// Cyberpunk neons on a near-black bar, spaced around the wheel and deliberately free of yellow and orange -
|
|
13
|
+
// those read as a warning here, and the bar has enough to say without one. Keep the length one longer than
|
|
14
|
+
// PALETTE_SIZE in sessions.js (a test pins it): the workspace eats one slot, sessions rotate the rest.
|
|
15
|
+
const PALETTE = [
|
|
16
|
+
"#2fe58a", // spring green
|
|
17
|
+
"#1fe0cf", // turquoise
|
|
18
|
+
"#f24bff", // magenta
|
|
19
|
+
"#b45cff", // neon violet
|
|
20
|
+
"#29b6ff", // azure
|
|
21
|
+
"#ff2e88", // rose
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// A stable number for this window when there is no port to read (a proxy, or the default 80/443). Not a
|
|
25
|
+
// hash worth defending - it only has to be the same on every reload of the same address.
|
|
26
|
+
function hostSeed() {
|
|
27
|
+
let seed = 0;
|
|
28
|
+
for (const ch of location.host) seed = (seed * 31 + ch.codePointAt(0)) % 100_000;
|
|
29
|
+
return seed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The workspace's slot in the palette. Ports are handed out lowest-free-first per workspace, so neighbours
|
|
33
|
+
// land on different colours.
|
|
34
|
+
const WORKSPACE_SLOT = (Number(location.port) || hostSeed()) % PALETTE.length;
|
|
35
|
+
export const WORKSPACE_COLOR = PALETTE[WORKSPACE_SLOT];
|
|
36
|
+
// Every slot except the workspace's own, in palette order. A session index maps into this, so "never the
|
|
37
|
+
// workspace colour" is a property of the list rather than a rule someone has to remember.
|
|
38
|
+
const SESSION_COLORS = PALETTE.filter((_, slot) => slot !== WORKSPACE_SLOT);
|
|
39
|
+
|
|
40
|
+
// Handed to the stylesheet once, before anything is drawn: everything that carries the window's identity
|
|
41
|
+
// reads it from there, so there is one line in the page that decides what colour this workspace is.
|
|
42
|
+
document.documentElement.style.setProperty("--ws", WORKSPACE_COLOR);
|
|
43
|
+
|
|
44
|
+
export function colorFor(entry) {
|
|
45
|
+
return SESSION_COLORS[(entry.colorIndex ?? 0) % SESSION_COLORS.length];
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="chamba">
|
|
2
|
+
<title>chamba</title>
|
|
3
|
+
<!-- Terminal panel: the same dark the app uses, so the browser-tab icon matches the page. -->
|
|
4
|
+
<rect width="32" height="32" rx="7" fill="#0d1117" />
|
|
5
|
+
<!-- A spark: four points with a waist, each edge a curve bending in towards the centre. Filled and
|
|
6
|
+
stroked with the same gradient, which is what grows it to nearly fill the icon and keeps it
|
|
7
|
+
readable at 16px - the only size that really matters here. The gold is the product's own. -->
|
|
8
|
+
<defs>
|
|
9
|
+
<linearGradient id="spark" x1="0" y1="0" x2="0" y2="1">
|
|
10
|
+
<stop offset="0" stop-color="#f0c078" />
|
|
11
|
+
<stop offset="1" stop-color="#d08a3c" />
|
|
12
|
+
</linearGradient>
|
|
13
|
+
</defs>
|
|
14
|
+
<path
|
|
15
|
+
d="M16 3 Q17.6 14.4 29 16 Q17.6 17.6 16 29 Q14.4 17.6 3 16 Q14.4 14.4 16 3 Z"
|
|
16
|
+
fill="url(#spark)"
|
|
17
|
+
stroke="url(#spark)"
|
|
18
|
+
stroke-width="2"
|
|
19
|
+
stroke-linejoin="round"
|
|
20
|
+
/>
|
|
21
|
+
</svg>
|