chamba 0.3.0 → 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 -24
- 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 -1691
- package/dist/server.js +0 -1919
- 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,353 @@
|
|
|
1
|
+
// pane.js - the web pane: the pages an agent published, beside the terminal it published them from.
|
|
2
|
+
//
|
|
3
|
+
// The pane is chrome this interface draws - the chips bar, the counter, the spine - around a page it does
|
|
4
|
+
// not trust (pane-frame.js renders that part). Keeping the two apart is the point: a published page can
|
|
5
|
+
// never fake which session and which page the user is looking at, because everything that says so is drawn
|
|
6
|
+
// outside its frame.
|
|
7
|
+
//
|
|
8
|
+
// What the pane shows belongs to the attached session, and it follows the tabs: switching sessions switches
|
|
9
|
+
// the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is, and
|
|
10
|
+
// whether it is collapsed - and that survives the switch, and a reload.
|
|
11
|
+
//
|
|
12
|
+
// A page that arrives never steals the pane. It badges its chip (and ticks the spine's counter when the pane
|
|
13
|
+
// is collapsed) and waits to be clicked. The one exception is a pane with nothing in it: the first page of a
|
|
14
|
+
// session opens on arrival, because there is nothing to interrupt.
|
|
15
|
+
//
|
|
16
|
+
// The pane exists before any of that. A session that has published nothing still has its spine, and opening it
|
|
17
|
+
// says what the pane is for and what to ask for - a feature nobody can see is a feature nobody uses, and this
|
|
18
|
+
// one is asked for in words rather than found in a menu.
|
|
19
|
+
|
|
20
|
+
import { sendFrame } from "./connection.js";
|
|
21
|
+
import { chips, grip, pagebar, pageCount, pageDoc, pane, paneCollapse, paneNext, panePrev, spine, spineBadge } from "./dom.js";
|
|
22
|
+
import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js";
|
|
23
|
+
import { attachedSid } from "./state.js";
|
|
24
|
+
|
|
25
|
+
// Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
|
|
26
|
+
// threshold, and the width the spine itself takes.
|
|
27
|
+
const COLLAPSE_AT = 150;
|
|
28
|
+
// What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
|
|
29
|
+
const DEFAULT_FRACTION = 0.44;
|
|
30
|
+
const MAX_FRACTION = 0.7;
|
|
31
|
+
// The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
|
|
32
|
+
// it was put away at, a pane you have just asked for has to be wide enough to read a page in.
|
|
33
|
+
const MIN_OPEN_FRACTION = 1 / 3;
|
|
34
|
+
// The window's own pane preferences: how wide, and whether it is collapsed. Not the session's - a pane is
|
|
35
|
+
// furniture, and furniture does not move because you looked at another tab.
|
|
36
|
+
const GEOMETRY_KEY = "webterm-pane";
|
|
37
|
+
// Chips say how long ago a page arrived, so the bar goes stale sitting still. Cheap to redraw; nothing else
|
|
38
|
+
// on the page depends on it.
|
|
39
|
+
const AGE_TICK_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
// sid -> { pages: [{ id, title, mtime, unread }], notice, selected }. Kept for every session this window has
|
|
42
|
+
// heard about, so switching back to a tab lands on the page it was left on.
|
|
43
|
+
const panes = new Map();
|
|
44
|
+
|
|
45
|
+
let width = null;
|
|
46
|
+
// null means nobody has said. An untouched pane keeps out of the way while its session has nothing in it, and
|
|
47
|
+
// is open once it has - which is the first page of a session opening on arrival, as it always did. A click on
|
|
48
|
+
// the spine or on the collapse button is an answer, and from then on it is the only one that counts.
|
|
49
|
+
let collapsed = null;
|
|
50
|
+
// What the last render decided, for the two things that only need to know whether the pane is showing.
|
|
51
|
+
let shut = true;
|
|
52
|
+
|
|
53
|
+
// --- What the window remembers -----------------------------------------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
|
|
57
|
+
if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
|
|
58
|
+
if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
|
|
59
|
+
} catch {
|
|
60
|
+
// A disabled or full store only means the pane opens at its default width.
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rememberGeometry() {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed }));
|
|
66
|
+
} catch {
|
|
67
|
+
// The pane still works for this page's lifetime.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- The pane's own state ----------------------------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function paneFor(sid) {
|
|
74
|
+
let entry = panes.get(sid);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
entry = { pages: [], notice: null, selected: null };
|
|
77
|
+
panes.set(sid, entry);
|
|
78
|
+
}
|
|
79
|
+
return entry;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The pages frame for a session: the list as it now stands, and why it is not the conversation's own. */
|
|
83
|
+
export function applyPages(msg) {
|
|
84
|
+
if (typeof msg.sid !== "string") return;
|
|
85
|
+
const entry = paneFor(msg.sid);
|
|
86
|
+
entry.pages = Array.isArray(msg.pages) ? msg.pages : [];
|
|
87
|
+
entry.notice = msg.notice ?? null;
|
|
88
|
+
// A page the user was reading can go: the file was moved, or the pane was adopted into another
|
|
89
|
+
// conversation's directory while this window was looking at it.
|
|
90
|
+
if (entry.selected !== null && !entry.pages.some((page) => page.id === entry.selected)) entry.selected = null;
|
|
91
|
+
// The one time a page selects itself. An empty pane has nothing to interrupt, so the first page to
|
|
92
|
+
// arrive opens; from then on an arrival is a badge and a click.
|
|
93
|
+
if (entry.selected === null && entry.pages.length === 1) select(msg.sid, entry.pages[0].id, { user: false });
|
|
94
|
+
if (msg.sid === attachedSid) render();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Sessions that are gone take their pane state with them. The files on disk are untouched; this is a window. */
|
|
98
|
+
export function prunePanes(live) {
|
|
99
|
+
for (const sid of [...panes.keys()]) {
|
|
100
|
+
if (!live.has(sid)) panes.delete(sid);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Draw the pane for whatever session this window is now on, asking for its pages when this window has never
|
|
106
|
+
* been told them. The server pushes the list when a window lands on a session, so this covers the frame that
|
|
107
|
+
* did not arrive - a socket replaced mid-attach, or a window that took a session over from another one.
|
|
108
|
+
*/
|
|
109
|
+
export function refreshPane() {
|
|
110
|
+
if (attachedSid !== null && !panes.has(attachedSid)) sendFrame({ t: "pages" });
|
|
111
|
+
render();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- Selecting and showing a page --------------------------------------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
// The page shown last, so an ordinary redraw (a chip's age, another session's frame) does not reload the
|
|
117
|
+
// document under the user and lose their scroll position or a half-filled form.
|
|
118
|
+
let shown = { sid: null, id: null };
|
|
119
|
+
|
|
120
|
+
function select(sid, id, { user }) {
|
|
121
|
+
const entry = paneFor(sid);
|
|
122
|
+
entry.selected = id;
|
|
123
|
+
// Reading is what makes a page read, and the server owns that: it answers with a fresh list, which is
|
|
124
|
+
// also what clears the badge. Marking it here as well only makes the two disagree for a moment.
|
|
125
|
+
if (user) show(sid, id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function show(sid, id) {
|
|
129
|
+
if (shown.sid === sid && shown.id === id) return;
|
|
130
|
+
shown = { sid, id };
|
|
131
|
+
let html;
|
|
132
|
+
try {
|
|
133
|
+
html = await fetchPage(sid, id);
|
|
134
|
+
} catch {
|
|
135
|
+
// The page is gone, or too large to serve. The list will say so on the next frame; until then the
|
|
136
|
+
// pane says it plainly rather than sitting on the previous page.
|
|
137
|
+
if (shown.sid === sid && shown.id === id) {
|
|
138
|
+
shown = { sid: null, id: null };
|
|
139
|
+
dropPage(pageDoc);
|
|
140
|
+
pageDoc.append(emptyState("That page could not be opened."));
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// The user clicked something else while this was in flight.
|
|
145
|
+
if (shown.sid !== sid || shown.id !== id) return;
|
|
146
|
+
renderPage(pageDoc, {
|
|
147
|
+
html,
|
|
148
|
+
onFeedback: (submission) => submitFeedback(sid, id, submission),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Drawing -----------------------------------------------------------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function emptyState(line) {
|
|
155
|
+
const el = document.createElement("p");
|
|
156
|
+
el.className = "pane-empty";
|
|
157
|
+
el.textContent = line;
|
|
158
|
+
return el;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Three things you might say, because the pane is asked for in words. Every agent already knows how to
|
|
162
|
+
// publish (its injected context says so); what is missing is the person knowing they can ask.
|
|
163
|
+
const INTRO_ASKS = ['"show me the plan as a page"', '"put the comparison in the pane"', '"ask me the rest as a form"'];
|
|
164
|
+
|
|
165
|
+
// What a session that has published nothing shows once its pane is opened. It is the only place the feature
|
|
166
|
+
// introduces itself, so it also carries the half nobody guesses: a page can ask something back.
|
|
167
|
+
function introState() {
|
|
168
|
+
const wrap = document.createElement("div");
|
|
169
|
+
wrap.className = "pane-intro";
|
|
170
|
+
const title = document.createElement("h2");
|
|
171
|
+
title.textContent = "The web pane";
|
|
172
|
+
const lede = document.createElement("p");
|
|
173
|
+
lede.textContent = "Ask the agent for a page whenever the answer is longer or more structured than the terminal carries well:";
|
|
174
|
+
const asks = document.createElement("ul");
|
|
175
|
+
for (const ask of INTRO_ASKS) {
|
|
176
|
+
const item = document.createElement("li");
|
|
177
|
+
item.textContent = ask;
|
|
178
|
+
asks.append(item);
|
|
179
|
+
}
|
|
180
|
+
const forms = document.createElement("p");
|
|
181
|
+
forms.textContent = "A page can ask you things too - what you submit goes straight back to the agent.";
|
|
182
|
+
wrap.append(title, lede, asks, forms);
|
|
183
|
+
return wrap;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// "now", "6 m", "3 h", "2 d" - short enough for a chip, and never a date, because everything in the pane
|
|
187
|
+
// happened during this conversation.
|
|
188
|
+
function ago(mtime) {
|
|
189
|
+
const seconds = Math.max(0, Math.round((Date.now() - mtime) / 1000));
|
|
190
|
+
if (seconds < 60) return "now";
|
|
191
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)} m`;
|
|
192
|
+
if (seconds < 86_400) return `${Math.floor(seconds / 3600)} h`;
|
|
193
|
+
return `${Math.floor(seconds / 86_400)} d`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function chipFor(sid, page, selected) {
|
|
197
|
+
const chip = document.createElement("button");
|
|
198
|
+
chip.type = "button";
|
|
199
|
+
chip.className = selected ? "chip sel" : "chip";
|
|
200
|
+
chip.dataset.id = page.id;
|
|
201
|
+
const title = document.createElement("span");
|
|
202
|
+
title.className = "ct";
|
|
203
|
+
title.textContent = page.title;
|
|
204
|
+
const time = document.createElement("span");
|
|
205
|
+
time.className = "time";
|
|
206
|
+
time.textContent = ago(page.mtime);
|
|
207
|
+
chip.append(title, time);
|
|
208
|
+
if (page.unread) {
|
|
209
|
+
const badge = document.createElement("span");
|
|
210
|
+
badge.className = "new";
|
|
211
|
+
badge.textContent = "new";
|
|
212
|
+
chip.append(badge);
|
|
213
|
+
}
|
|
214
|
+
chip.addEventListener("click", () => {
|
|
215
|
+
select(sid, page.id, { user: true });
|
|
216
|
+
render();
|
|
217
|
+
});
|
|
218
|
+
return chip;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// How wide, and collapsed or not. Its own function because a drag is the one thing that changes the pane
|
|
222
|
+
// without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
|
|
223
|
+
function applyGeometry(collapsedNow = shut) {
|
|
224
|
+
pane.classList.toggle("collapsed", collapsedNow);
|
|
225
|
+
// The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
|
|
226
|
+
// last word: the collapsed spine and the narrow-screen layout are rules, and an inline width would beat
|
|
227
|
+
// both of them.
|
|
228
|
+
pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function render() {
|
|
232
|
+
const sid = attachedSid;
|
|
233
|
+
const entry = sid === null ? null : (panes.get(sid) ?? null);
|
|
234
|
+
const pages = entry?.pages ?? [];
|
|
235
|
+
const unread = pages.filter((page) => page.unread).length;
|
|
236
|
+
|
|
237
|
+
// Nothing published in this session, and nothing to say about it. The pane stays - as the spine, until
|
|
238
|
+
// someone opens it - because a pane that only appears once an agent has used it is one nobody knows to
|
|
239
|
+
// ask for. The grip is the part that goes: there is no width worth choosing for a page that does not exist.
|
|
240
|
+
const bare = pages.length === 0 && !entry?.notice;
|
|
241
|
+
shut = collapsed === null ? bare : collapsed;
|
|
242
|
+
pane.hidden = false;
|
|
243
|
+
grip.hidden = bare && shut;
|
|
244
|
+
|
|
245
|
+
applyGeometry(shut);
|
|
246
|
+
spineBadge.textContent = unread > 0 ? String(unread) : "";
|
|
247
|
+
spineBadge.hidden = unread === 0;
|
|
248
|
+
|
|
249
|
+
chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
|
|
250
|
+
const at = pages.findIndex((page) => page.id === entry?.selected);
|
|
251
|
+
pageCount.textContent = bare ? "no pages yet" : `${at === -1 ? 0 : at + 1} / ${pages.length}`;
|
|
252
|
+
// The arrows are the mock's way through a bar too narrow to hold every chip.
|
|
253
|
+
const overflowing = chips.scrollWidth > chips.clientWidth + 1;
|
|
254
|
+
panePrev.hidden = !overflowing;
|
|
255
|
+
paneNext.hidden = !overflowing;
|
|
256
|
+
|
|
257
|
+
if (bare) {
|
|
258
|
+
dropPage(pageDoc);
|
|
259
|
+
shown = { sid: null, id: null };
|
|
260
|
+
pageDoc.append(introState());
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// A session refused adoption keeps a pane of its own, and publishes into it, so its chips have to open
|
|
265
|
+
// like any other. The notice explains where the conversation's earlier pages went, and stands in for
|
|
266
|
+
// "Pick a page above." until this session has one open.
|
|
267
|
+
if (entry === null || entry.selected === null) {
|
|
268
|
+
const notice =
|
|
269
|
+
entry?.notice === "shared"
|
|
270
|
+
? "This conversation's pages are open in another session, so this one keeps a pane of its own."
|
|
271
|
+
: "Pick a page above.";
|
|
272
|
+
dropPage(pageDoc);
|
|
273
|
+
shown = { sid: null, id: null };
|
|
274
|
+
pageDoc.append(emptyState(notice));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
show(sid, entry.selected);
|
|
278
|
+
// A chip that scrolled out of the bar while the user was reading something else.
|
|
279
|
+
chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
setInterval(() => {
|
|
283
|
+
if (!shut) render();
|
|
284
|
+
}, AGE_TICK_MS);
|
|
285
|
+
|
|
286
|
+
// --- The divider, and the spine ----------------------------------------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
// Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
|
|
289
|
+
// spine rather than a sliver. Pointer capture keeps the drag alive over the terminal and the frame alike -
|
|
290
|
+
// a page is a document with its own event loop, and without capture the pointer would be lost in it.
|
|
291
|
+
let dragging = false;
|
|
292
|
+
// The width the pane had when the drag started. Dragging it shut is a request to put it away, not a request
|
|
293
|
+
// for a 150px pane, so crossing the threshold hands its old width back rather than keeping the sliver the
|
|
294
|
+
// pointer passed through on the way there.
|
|
295
|
+
let widthBeforeDrag = null;
|
|
296
|
+
|
|
297
|
+
grip.addEventListener("pointerdown", (event) => {
|
|
298
|
+
dragging = true;
|
|
299
|
+
widthBeforeDrag = width;
|
|
300
|
+
document.body.classList.add("dragging");
|
|
301
|
+
grip.setPointerCapture(event.pointerId);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
grip.addEventListener("pointermove", (event) => {
|
|
305
|
+
if (!dragging) return;
|
|
306
|
+
const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
|
|
307
|
+
if (wanted < COLLAPSE_AT) {
|
|
308
|
+
collapsed = true;
|
|
309
|
+
width = widthBeforeDrag;
|
|
310
|
+
} else {
|
|
311
|
+
collapsed = false;
|
|
312
|
+
width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
|
|
313
|
+
}
|
|
314
|
+
// A drag is an answer too, so what it decides is what the next render starts from.
|
|
315
|
+
shut = collapsed;
|
|
316
|
+
applyGeometry(shut);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
function endDrag() {
|
|
320
|
+
if (!dragging) return;
|
|
321
|
+
dragging = false;
|
|
322
|
+
document.body.classList.remove("dragging");
|
|
323
|
+
rememberGeometry();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
grip.addEventListener("pointerup", endDrag);
|
|
327
|
+
grip.addEventListener("pointercancel", endDrag);
|
|
328
|
+
|
|
329
|
+
// Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
|
|
330
|
+
// resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
|
|
331
|
+
function setCollapsed(next) {
|
|
332
|
+
collapsed = next;
|
|
333
|
+
shut = next;
|
|
334
|
+
// Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one from
|
|
335
|
+
// a window remembered narrower than this one, has to open into something a page fits in.
|
|
336
|
+
if (!collapsed) {
|
|
337
|
+
const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
|
|
338
|
+
if (width !== null && width < floor) width = floor;
|
|
339
|
+
}
|
|
340
|
+
rememberGeometry();
|
|
341
|
+
render();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
paneCollapse.addEventListener("click", () => setCollapsed(true));
|
|
345
|
+
spine.addEventListener("click", () => setCollapsed(false));
|
|
346
|
+
|
|
347
|
+
// The chips bar scrolls by roughly a bar's worth, which is what the arrows in the mock do.
|
|
348
|
+
panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
349
|
+
paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
|
|
350
|
+
// A bar that fits at one width may not at another, and the arrows only exist when it does not.
|
|
351
|
+
window.addEventListener("resize", render);
|
|
352
|
+
// The pagebar is chrome: a click on it must not leave the terminal without focus for no reason.
|
|
353
|
+
pagebar.addEventListener("mousedown", (event) => event.preventDefault());
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// state.js - what the server last told this window, and the key the window was opened with.
|
|
2
|
+
//
|
|
3
|
+
// Everything the session bar draws comes from the server, so every window agrees on it. It lives here because
|
|
4
|
+
// most of the page reads it and only the frame router writes it: the readers import it as a live binding, so
|
|
5
|
+
// there is one copy of the truth, and the writing is in one place rather than wherever a frame arrived.
|
|
6
|
+
|
|
7
|
+
// Everything the session bar draws comes from the server, so every window agrees on it.
|
|
8
|
+
export let sessions = [];
|
|
9
|
+
export let attachedSid = null;
|
|
10
|
+
export let maxSessions = 8;
|
|
11
|
+
// Which agent "+ New session" starts, and every agent this container will run. A session's own agent is on
|
|
12
|
+
// its entry - several can be live at once - so these two are only about starting the next one.
|
|
13
|
+
export let defaultAgent = "the agent";
|
|
14
|
+
export let agents = [];
|
|
15
|
+
export let workspaceName = "";
|
|
16
|
+
// Where "+ New session" starts an agent, relative to the workspace root ("" is the root itself). The server
|
|
17
|
+
// owns it - it follows the directory chamba was last run in - and the picker opens on it.
|
|
18
|
+
export let defaultCwd = "";
|
|
19
|
+
|
|
20
|
+
// A whole `sessions` frame, taken in one go. The frame router calls this and then redraws everything that
|
|
21
|
+
// reads it; nothing else writes any of it.
|
|
22
|
+
export function adoptSessionsFrame(msg) {
|
|
23
|
+
sessions = Array.isArray(msg.list) ? msg.list : [];
|
|
24
|
+
attachedSid = msg.attachedSid ?? null;
|
|
25
|
+
maxSessions = msg.max ?? maxSessions;
|
|
26
|
+
defaultAgent = msg.agent || defaultAgent;
|
|
27
|
+
if (Array.isArray(msg.agents)) agents = msg.agents;
|
|
28
|
+
workspaceName = msg.workspace || "";
|
|
29
|
+
defaultCwd = msg.defaultCwd ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Which session this window is driving, or null for none. Moved by the frames that move it: a replay lands on
|
|
33
|
+
// one, a takeover or an exit leaves the window on nothing at all.
|
|
34
|
+
export function setAttachedSid(sid) {
|
|
35
|
+
attachedSid = sid;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The label a session tab is showing, for a sentence about a session that may already be gone.
|
|
39
|
+
export function labelOf(sid, fallback) {
|
|
40
|
+
return sessions.find((entry) => entry.id === sid)?.label ?? fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The key that came with the URL. The server mints a new one every time it starts and refuses everything
|
|
44
|
+
// without it, so this is also what goes stale: a window left open across a restart still holds the old key,
|
|
45
|
+
// which is the case the curtain explains rather than reconnecting forever.
|
|
46
|
+
export const WEB_KEY = new URLSearchParams(location.search).get("k") ?? "";
|
|
47
|
+
export const KEY_QUERY = `?k=${encodeURIComponent(WEB_KEY)}`;
|
|
48
|
+
// The header the pane sends the key in. A published page's content must never be reachable at a URL that
|
|
49
|
+
// carries the key, because a script inside a page can read the URL of the document it is in - so the two
|
|
50
|
+
// pane routes take the key here instead, and the query form stays for everything else.
|
|
51
|
+
export const KEY_HEADER = "X-Webterm-Key";
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// status-strip.js - the attached session's own numbers, in one line above the composer.
|
|
2
|
+
//
|
|
3
|
+
// In a terminal this is the Claude Code status line. In chamba it is not: the script that would draw it prints
|
|
4
|
+
// nothing and writes a snapshot instead, and the server turns that file into a frame. The reason is that a
|
|
5
|
+
// status line is redrawn inside the conversation, which in a browser means it scrolls away with the output and
|
|
6
|
+
// takes a slice of the terminal every prompt; here it sits still, outside the terminal, where it belongs.
|
|
7
|
+
//
|
|
8
|
+
// The strip is the attached session's, like the pane: switching tabs switches the numbers, and a session with
|
|
9
|
+
// no snapshot has no strip at all. Only claude writes snapshots, so a codex tab is a terminal and a composer
|
|
10
|
+
// and nothing else - the server never sends a frame for one, which is what keeps the strip from flickering
|
|
11
|
+
// into existence empty.
|
|
12
|
+
|
|
13
|
+
import { sendFrame } from "./connection.js";
|
|
14
|
+
import { strip } from "./dom.js";
|
|
15
|
+
import { attachedSid } from "./state.js";
|
|
16
|
+
|
|
17
|
+
// The countdown to the quota recharge goes stale sitting still, and so does nothing else here. A redraw is
|
|
18
|
+
// a handful of spans, so the whole strip is rebuilt rather than the one number patched.
|
|
19
|
+
const TICK_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
// Where the bars change colour. The quota is an energy meter, so it warns as it drains; the context is a
|
|
22
|
+
// filling one, so it warns as it fills. Both say the same thing in the end - it is time to do something.
|
|
23
|
+
const QUOTA_WARN_AT = 50;
|
|
24
|
+
const QUOTA_LOW_AT = 20;
|
|
25
|
+
const CONTEXT_WARN_AT = 70;
|
|
26
|
+
const CONTEXT_FULL_AT = 90;
|
|
27
|
+
|
|
28
|
+
// sid -> the session's status, or null when it has none yet. Kept per session so switching tabs is a redraw
|
|
29
|
+
// rather than a round trip.
|
|
30
|
+
const statuses = new Map();
|
|
31
|
+
|
|
32
|
+
// Sessions this window has already asked about. The server pushes a session's status when the window lands on
|
|
33
|
+
// it, so asking is only for the frame that did not arrive - and a session that will never have one (any agent
|
|
34
|
+
// but claude) must not be asked again every time the bar redraws.
|
|
35
|
+
const asked = new Set();
|
|
36
|
+
|
|
37
|
+
/** The snapshot frame for a session: everything the strip draws, already normalised by the server. */
|
|
38
|
+
export function applySnapshot(msg) {
|
|
39
|
+
if (typeof msg.sid !== "string") return;
|
|
40
|
+
statuses.set(msg.sid, msg.status ?? null);
|
|
41
|
+
if (msg.sid === attachedSid) render();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Sessions that are gone take their numbers with them. */
|
|
45
|
+
export function pruneStatuses(live) {
|
|
46
|
+
for (const sid of [...statuses.keys()]) {
|
|
47
|
+
if (!live.has(sid)) statuses.delete(sid);
|
|
48
|
+
}
|
|
49
|
+
for (const sid of [...asked]) {
|
|
50
|
+
if (!live.has(sid)) asked.delete(sid);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Draw the strip for whatever session this window is now on, asking for its numbers when it has none. */
|
|
55
|
+
export function refreshStrip() {
|
|
56
|
+
if (attachedSid !== null && !statuses.has(attachedSid) && !asked.has(attachedSid)) {
|
|
57
|
+
asked.add(attachedSid);
|
|
58
|
+
sendFrame({ t: "snapshot" });
|
|
59
|
+
}
|
|
60
|
+
render();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Saying a number ---------------------------------------------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** 45.0k, 174k, 1M - a token count at a glance, never to the token. */
|
|
66
|
+
function short(value) {
|
|
67
|
+
if (value >= 1_000_000) return `${trimZero(value / 1_000_000)}M`;
|
|
68
|
+
if (value >= 100_000) return `${Math.round(value / 1000)}k`;
|
|
69
|
+
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
|
70
|
+
return String(Math.round(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function trimZero(value) {
|
|
74
|
+
const text = value.toFixed(1);
|
|
75
|
+
return text.endsWith(".0") ? text.slice(0, -2) : text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** How long until a moment: "2h 15m", "15m", or "now" once it has passed. */
|
|
79
|
+
function until(epochSeconds) {
|
|
80
|
+
const seconds = Math.round(epochSeconds - Date.now() / 1000);
|
|
81
|
+
if (seconds <= 0) return "now";
|
|
82
|
+
if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
|
|
83
|
+
return `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Drawing -----------------------------------------------------------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// The icons and the separator the mock draws, written as escapes so this file stays plain ASCII: an emoji
|
|
89
|
+
// that arrives as a mojibake through some editor or pipe is a bug nobody sees until it is on screen.
|
|
90
|
+
const ROBOT = "\u{1F916}";
|
|
91
|
+
const BRAIN = "\u{1F9E0}";
|
|
92
|
+
const BOLT = "\u26A1";
|
|
93
|
+
const PLUG = "\u{1F50C}";
|
|
94
|
+
const DOT = "\u00B7";
|
|
95
|
+
|
|
96
|
+
function span(className, textContent) {
|
|
97
|
+
const el = document.createElement("span");
|
|
98
|
+
if (className) el.className = className;
|
|
99
|
+
if (textContent !== undefined) el.textContent = textContent;
|
|
100
|
+
return el;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One segment of the strip. Everything in it is text this interface wrote, so nothing here parses markup. */
|
|
104
|
+
function segment(...parts) {
|
|
105
|
+
const seg = span("seg");
|
|
106
|
+
seg.append(...parts);
|
|
107
|
+
return seg;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A meter, filled to a percentage of itself. `tone` is what the fill says about that number. */
|
|
111
|
+
function bar(percent, tone) {
|
|
112
|
+
const el = span(tone ? `bar ${tone}` : "bar");
|
|
113
|
+
const fill = span("fill");
|
|
114
|
+
fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
115
|
+
el.append(fill);
|
|
116
|
+
return el;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function modelSegment(status) {
|
|
120
|
+
if (status.model === null) return null;
|
|
121
|
+
const parts = [span("icon", ROBOT), span("name", status.model)];
|
|
122
|
+
if (status.effort !== null) parts.push(span("dim", status.effort));
|
|
123
|
+
return segment(...parts);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Tokens held, against the window they are held in. The window half is dropped when Claude Code did not say
|
|
127
|
+
// how big it is, and the bar with it - a bar with no scale is decoration.
|
|
128
|
+
function contextSegment(status) {
|
|
129
|
+
if (status.tokens === null) return null;
|
|
130
|
+
const parts = [span("icon", BRAIN), span("name", short(status.tokens))];
|
|
131
|
+
if (status.windowSize !== null && status.windowSize > 0) parts.push(span("dim", `/ ${short(status.windowSize)}`));
|
|
132
|
+
if (status.contextPct !== null) {
|
|
133
|
+
const tone = status.contextPct >= CONTEXT_FULL_AT ? "low" : status.contextPct >= CONTEXT_WARN_AT ? "warn" : "";
|
|
134
|
+
parts.push(bar(status.contextPct, tone), span("dim", `${Math.round(status.contextPct)}%`));
|
|
135
|
+
}
|
|
136
|
+
return segment(...parts);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// What is left of the five-hour window, and when it comes back. Absent on a free account and before the first
|
|
140
|
+
// answer of a session, which is why the whole segment goes rather than showing a full meter that is a guess.
|
|
141
|
+
function quotaSegment(status) {
|
|
142
|
+
if (status.quotaPct === null) return null;
|
|
143
|
+
const tone = status.quotaPct <= QUOTA_LOW_AT ? "low" : status.quotaPct <= QUOTA_WARN_AT ? "warn" : "";
|
|
144
|
+
const parts = [span("icon", BOLT), bar(status.quotaPct, tone), span("name", `${Math.round(status.quotaPct)}%`)];
|
|
145
|
+
if (status.quotaResetsAt !== null) parts.push(span("dim", `(${PLUG} ${until(status.quotaResetsAt)})`));
|
|
146
|
+
return segment(...parts);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function versionSegment(status) {
|
|
150
|
+
return status.version === null ? null : segment(span("dim", `Claude Code v${status.version}`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function render() {
|
|
154
|
+
const status = attachedSid === null ? null : (statuses.get(attachedSid) ?? null);
|
|
155
|
+
const segments = status === null ? [] : [modelSegment(status), contextSegment(status), quotaSegment(status), versionSegment(status)];
|
|
156
|
+
const shown = segments.filter((seg) => seg !== null);
|
|
157
|
+
// A snapshot every field of which was missing says nothing, and an empty bar above the composer is worse
|
|
158
|
+
// than no bar: it looks like something failed to load.
|
|
159
|
+
strip.hidden = shown.length === 0;
|
|
160
|
+
const line = [];
|
|
161
|
+
for (const seg of shown) {
|
|
162
|
+
if (line.length > 0) line.push(span("sep", DOT));
|
|
163
|
+
line.push(seg);
|
|
164
|
+
}
|
|
165
|
+
strip.replaceChildren(...line);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setInterval(() => {
|
|
169
|
+
if (!strip.hidden) render();
|
|
170
|
+
}, TICK_MS);
|