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,244 @@
|
|
|
1
|
+
// drafts.js - the two stores this window keeps per session: what has not been sent, and what already was.
|
|
2
|
+
//
|
|
3
|
+
// Both belong to the browser window rather than to the container, both are keyed by session id, and both go
|
|
4
|
+
// when their session does - so they are one module and share the pruning that ends them.
|
|
5
|
+
|
|
6
|
+
import { autoGrow, imageCounter, pendingImages, setImageCounter } from "./composer.js";
|
|
7
|
+
import { input } from "./dom.js";
|
|
8
|
+
import { noteMsg } from "./note.js";
|
|
9
|
+
import { attachedSid, labelOf, sessions } from "./state.js";
|
|
10
|
+
|
|
11
|
+
// --- Per-session drafts ------------------------------------------------------------------------------------------------------------------
|
|
12
|
+
//
|
|
13
|
+
// A half-written message belongs to the conversation it was written for, so the box is swapped when you
|
|
14
|
+
// switch tabs and emptied when the session ends. Three things move together: the text, the image tokens in
|
|
15
|
+
// it, and the counter those tokens come from - a token only means something next to the map that expands it,
|
|
16
|
+
// so one shared map would let [Image #1] in one session resolve to another session's file.
|
|
17
|
+
//
|
|
18
|
+
// The draft is this window's. It survives a switch, a reload and a reconnect after sleep (sessionStorage,
|
|
19
|
+
// where the last attached session is already remembered), but it does not follow the session into another
|
|
20
|
+
// browser window: an unsent message stays where it was typed.
|
|
21
|
+
|
|
22
|
+
const DRAFTS_KEY = "webterm-drafts";
|
|
23
|
+
// How long after the last keystroke the drafts reach storage. Every switch, send and close writes at once,
|
|
24
|
+
// so this only covers reloading mid-sentence.
|
|
25
|
+
const DRAFT_PERSIST_MS = 500;
|
|
26
|
+
|
|
27
|
+
// sid -> { text, images: [[token, path], ...], counter }. Images are pairs rather than a Map so a draft is
|
|
28
|
+
// plain JSON.
|
|
29
|
+
const drafts = new Map();
|
|
30
|
+
let draftTimer = null;
|
|
31
|
+
|
|
32
|
+
export function persistDrafts() {
|
|
33
|
+
if (draftTimer) {
|
|
34
|
+
clearTimeout(draftTimer);
|
|
35
|
+
draftTimer = null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
sessionStorage.setItem(DRAFTS_KEY, JSON.stringify([...drafts]));
|
|
39
|
+
} catch {
|
|
40
|
+
// A disabled or full store must not break typing; the drafts still work for this page's lifetime.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function schedulePersist() {
|
|
45
|
+
if (draftTimer) return;
|
|
46
|
+
draftTimer = setTimeout(() => {
|
|
47
|
+
draftTimer = null;
|
|
48
|
+
persistDrafts();
|
|
49
|
+
}, DRAFT_PERSIST_MS);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Storage is a convenience: anything unreadable or malformed just means the composer starts empty.
|
|
53
|
+
try {
|
|
54
|
+
const stored = JSON.parse(sessionStorage.getItem(DRAFTS_KEY) ?? "[]");
|
|
55
|
+
if (Array.isArray(stored)) {
|
|
56
|
+
for (const pair of stored) {
|
|
57
|
+
const [sid, draft] = Array.isArray(pair) ? pair : [];
|
|
58
|
+
if (typeof sid === "string" && draft && typeof draft.text === "string") drafts.set(sid, draft);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Nothing to restore.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A reload keeps what was typed rather than eating it, including the last few keystrokes.
|
|
66
|
+
window.addEventListener("pagehide", persistDrafts);
|
|
67
|
+
|
|
68
|
+
// The composer as it stands now, stored under the session it was typed for. An empty box stores nothing, so
|
|
69
|
+
// clicking through sessions does not pile up empty drafts. Callers decide whether the write is urgent.
|
|
70
|
+
export function captureDraft(sid) {
|
|
71
|
+
if (!sid) return;
|
|
72
|
+
if (input.value) drafts.set(sid, { text: input.value, images: [...pendingImages], counter: imageCounter });
|
|
73
|
+
else drafts.delete(sid);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function restoreDraft(sid) {
|
|
77
|
+
const draft = drafts.get(sid);
|
|
78
|
+
input.value = draft?.text ?? "";
|
|
79
|
+
pendingImages.clear();
|
|
80
|
+
for (const [token, path] of draft?.images ?? []) pendingImages.set(token, path);
|
|
81
|
+
setImageCounter(draft?.counter ?? 0);
|
|
82
|
+
// Each session has its own history, so a walk never carries across a switch.
|
|
83
|
+
endHistoryWalk();
|
|
84
|
+
autoGrow();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The session is gone, so its unsent message goes with it.
|
|
88
|
+
export function dropDraft(sid) {
|
|
89
|
+
if (sid && drafts.delete(sid)) persistDrafts();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Drop everything this window is holding for a session that is no longer in the bar, and say whether anything went.
|
|
93
|
+
// Shared by the two stores kept per session - the unsent message and the history below - because both end the same
|
|
94
|
+
// way at the same moment. This is what covers a session closed from another window, which arrives as a bar without
|
|
95
|
+
// it rather than as an exit.
|
|
96
|
+
function pruneBySession(store) {
|
|
97
|
+
if (store.size === 0) return false;
|
|
98
|
+
const live = new Set(sessions.map((session) => session.id));
|
|
99
|
+
let removed = false;
|
|
100
|
+
for (const sid of [...store.keys()]) {
|
|
101
|
+
if (live.has(sid)) continue;
|
|
102
|
+
store.delete(sid);
|
|
103
|
+
removed = true;
|
|
104
|
+
}
|
|
105
|
+
return removed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function pruneDrafts() {
|
|
109
|
+
if (pruneBySession(drafts)) persistDrafts();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The window moved on before an upload finished. The token still belongs to the session the image was
|
|
113
|
+
// attached to, so it goes onto that session's draft, numbered by that draft's own counter.
|
|
114
|
+
export function addImageToDraft(sid, path) {
|
|
115
|
+
const draft = drafts.get(sid) ?? { text: "", images: [], counter: 0 };
|
|
116
|
+
const counter = draft.counter + 1;
|
|
117
|
+
const token = `[Image #${counter}]`;
|
|
118
|
+
const lead = draft.text && !draft.text.endsWith(" ") && !draft.text.endsWith("\n") ? " " : "";
|
|
119
|
+
drafts.set(sid, { text: `${draft.text}${lead}${token} `, images: [...draft.images, [token, path]], counter });
|
|
120
|
+
persistDrafts();
|
|
121
|
+
noteMsg(`image added to ${labelOf(sid, "the session it was attached to")}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- What you already sent ---------------------------------------------------------------------------------------------------------------
|
|
125
|
+
//
|
|
126
|
+
// Up in an empty box brings back the last message, the way a shell does. The composer exists so a long message can be
|
|
127
|
+
// written properly, and the messages worth having back are exactly the long ones: a prompt that needed one more
|
|
128
|
+
// sentence, a path that was almost right, a question worth asking again of a different session.
|
|
129
|
+
//
|
|
130
|
+
// Two rules keep it out of the way of ordinary typing. It only starts from an empty box, so Up can never snatch away
|
|
131
|
+
// something half-written. And once it has started, Up and Down only step on from the first and last line of what is
|
|
132
|
+
// showing, so the arrows still walk around a recalled message the way they walk around any other text - a shell with
|
|
133
|
+
// a multi-line buffer behaves the same, and anything else is a trap. Typing ends the walk: what is in the box is
|
|
134
|
+
// yours again, and the arrows go back to being arrows.
|
|
135
|
+
//
|
|
136
|
+
// What is stored is what was actually sent, image tokens already expanded. A recalled message has to mean the same
|
|
137
|
+
// thing the second time, and [Image #1] means nothing once the box that numbered it has been emptied.
|
|
138
|
+
//
|
|
139
|
+
// The history is this window's, like the drafts above, and it holds what went through this box - not what was typed
|
|
140
|
+
// straight into the terminal, which the agent's own history already has.
|
|
141
|
+
|
|
142
|
+
const HISTORY_KEY = "webterm-history";
|
|
143
|
+
// Long enough to cover a working session, short enough that this never becomes somewhere data quietly accumulates.
|
|
144
|
+
const HISTORY_MAX = 50;
|
|
145
|
+
|
|
146
|
+
// sid -> [oldest, ..., newest].
|
|
147
|
+
const histories = new Map();
|
|
148
|
+
// How far back the walk has got in the attached session's history, or null when the box holds the user's own text.
|
|
149
|
+
export let historyAt = null;
|
|
150
|
+
|
|
151
|
+
function persistHistory() {
|
|
152
|
+
try {
|
|
153
|
+
sessionStorage.setItem(HISTORY_KEY, JSON.stringify([...histories]));
|
|
154
|
+
} catch {
|
|
155
|
+
// Storage that will not take it costs the recall after a reload and nothing else.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const stored = JSON.parse(sessionStorage.getItem(HISTORY_KEY) ?? "[]");
|
|
161
|
+
if (Array.isArray(stored)) {
|
|
162
|
+
for (const pair of stored) {
|
|
163
|
+
const [sid, list] = Array.isArray(pair) ? pair : [];
|
|
164
|
+
if (typeof sid !== "string" || !Array.isArray(list)) continue;
|
|
165
|
+
histories.set(
|
|
166
|
+
sid,
|
|
167
|
+
list.filter((line) => typeof line === "string"),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// Nothing to recall.
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
window.addEventListener("pagehide", persistHistory);
|
|
176
|
+
|
|
177
|
+
// The same message twice running is one entry: sending something again is normal, and a history filled with it is
|
|
178
|
+
// not worth walking through.
|
|
179
|
+
export function rememberSent(sid, text) {
|
|
180
|
+
if (!sid || !text) return;
|
|
181
|
+
const list = histories.get(sid) ?? [];
|
|
182
|
+
if (list[list.length - 1] !== text) list.push(text);
|
|
183
|
+
while (list.length > HISTORY_MAX) list.shift();
|
|
184
|
+
histories.set(sid, list);
|
|
185
|
+
persistHistory();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The session is gone, and everything said to it goes too.
|
|
189
|
+
export function dropHistory(sid) {
|
|
190
|
+
if (sid && histories.delete(sid)) persistHistory();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function pruneHistory() {
|
|
194
|
+
if (pruneBySession(histories)) persistHistory();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Where the caret is, in lines. The arrows only reach for history from the edges of what is in the box.
|
|
198
|
+
export function atFirstLine() {
|
|
199
|
+
return !input.value.slice(0, input.selectionStart ?? 0).includes("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function atLastLine() {
|
|
203
|
+
return !input.value.slice(input.selectionEnd ?? input.value.length).includes("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Put an entry in the box, caret at the end of it - which is where you carry on typing from. It becomes the session's
|
|
207
|
+
// draft like anything else in the box, so switching away and back does not lose a message you went and fetched.
|
|
208
|
+
export function showRecalled(text) {
|
|
209
|
+
input.value = text;
|
|
210
|
+
autoGrow();
|
|
211
|
+
const end = input.value.length;
|
|
212
|
+
input.setSelectionRange(end, end);
|
|
213
|
+
captureDraft(attachedSid);
|
|
214
|
+
schedulePersist();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Typing makes the box yours again.
|
|
218
|
+
export function endHistoryWalk() {
|
|
219
|
+
historyAt = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// One step through the attached session's history; `back` is Up. Says whether it moved, so a step that had nowhere
|
|
223
|
+
// to go leaves the keypress to the browser instead of swallowing it.
|
|
224
|
+
export function walkHistory(back) {
|
|
225
|
+
const list = histories.get(attachedSid) ?? [];
|
|
226
|
+
if (list.length === 0) return false;
|
|
227
|
+
if (back) {
|
|
228
|
+
// The oldest entry is the end of the road rather than a wrap back to the newest: a list that loops has no
|
|
229
|
+
// end, and you would never know you had seen all of it.
|
|
230
|
+
historyAt = historyAt === null ? list.length - 1 : Math.max(0, historyAt - 1);
|
|
231
|
+
showRecalled(list[historyAt]);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
if (historyAt === null) return false;
|
|
235
|
+
// Forward past the newest is the empty box the walk started from, not the newest all over again.
|
|
236
|
+
if (historyAt >= list.length - 1) {
|
|
237
|
+
endHistoryWalk();
|
|
238
|
+
showRecalled("");
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
historyAt += 1;
|
|
242
|
+
showRecalled(list[historyAt]);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// frames.js - what to do with a frame the server sent.
|
|
2
|
+
//
|
|
3
|
+
// One function per frame kind and nothing else: every frame either moves this window to a session, writes to the
|
|
4
|
+
// terminal, redraws the bar, or opens a card. It is the one module that talks to all the others, so nothing else
|
|
5
|
+
// has to know what a frame looks like.
|
|
6
|
+
|
|
7
|
+
import { refreshBrowserTab, scheduleChime } from "./alerts.js";
|
|
8
|
+
import { badAgentCard, badDirCard, capCard, stopFailedCard, takenCard, takeoverCard } from "./cards.js";
|
|
9
|
+
import { writeReplay } from "./clipboard.js";
|
|
10
|
+
import { clearComposer, refreshComposer } from "./composer.js";
|
|
11
|
+
import { hideCurtain, LAST_SID_KEY, noteSocketAlive, resumeReconnects, stoppingCurtain } from "./connection.js";
|
|
12
|
+
import { stopDictation } from "./dictation.js";
|
|
13
|
+
import { captureDraft, dropDraft, dropHistory, persistDrafts, pruneDrafts, pruneHistory, restoreDraft } from "./drafts.js";
|
|
14
|
+
import { noteMsg } from "./note.js";
|
|
15
|
+
import { applyPages, prunePanes, refreshPane } from "./pane.js";
|
|
16
|
+
import { adoptSessionsFrame, attachedSid, defaultAgent, sessions, setAttachedSid } from "./state.js";
|
|
17
|
+
import { applySnapshot, pruneStatuses, refreshStrip } from "./status-strip.js";
|
|
18
|
+
import { noteArrivals, renderBar } from "./tabs.js";
|
|
19
|
+
import { focusTerminal, scheduleRefresh, sendResize, term } from "./terminal.js";
|
|
20
|
+
|
|
21
|
+
// What the terminal is holding: either a live session's screen (`shownSid`), or a message where a session used
|
|
22
|
+
// to be (`shownMessage`). At most one of them is set. They are tracked because a session can end while this
|
|
23
|
+
// window is watching it - nothing replays over the dead screen then, and a dead screen looks exactly like a
|
|
24
|
+
// live one. The last screen of an agent that exited on its own is kept rather than replaced (it usually says
|
|
25
|
+
// why), so it counts as a message already delivered and no later bar update paints over it.
|
|
26
|
+
const EXITED_SCREEN = "exited";
|
|
27
|
+
let shownSid = null;
|
|
28
|
+
let shownMessage = null;
|
|
29
|
+
|
|
30
|
+
export function onFrame(msg) {
|
|
31
|
+
// Any frame at all proves the socket works, which is what a wake probe is waiting to hear.
|
|
32
|
+
noteSocketAlive();
|
|
33
|
+
if (msg.t === "sessions") {
|
|
34
|
+
applySessions(msg);
|
|
35
|
+
} else if (msg.t === "replay") {
|
|
36
|
+
// A replay for the session this window is already on is a reconnect or a session taken back, not a
|
|
37
|
+
// switch, so the composer is left exactly as it is. A real switch parks the outgoing draft, ends
|
|
38
|
+
// dictation, and loads the incoming session's draft once the screen is up.
|
|
39
|
+
const switching = attachedSid !== msg.sid;
|
|
40
|
+
if (switching) {
|
|
41
|
+
captureDraft(attachedSid);
|
|
42
|
+
persistDrafts();
|
|
43
|
+
stopDictation();
|
|
44
|
+
}
|
|
45
|
+
setAttachedSid(msg.sid);
|
|
46
|
+
sessionStorage.setItem(LAST_SID_KEY, msg.sid);
|
|
47
|
+
shownSid = msg.sid;
|
|
48
|
+
shownMessage = null;
|
|
49
|
+
// Reset first: the buffer is the whole screen, so writing it without a reset would paint it on
|
|
50
|
+
// top of whatever was there (the previous session, or this one before a reconnect).
|
|
51
|
+
term.reset();
|
|
52
|
+
// Through writeReplay, not term.write: a replayed screen still carries the OSC 52 of any copy made
|
|
53
|
+
// in that session, and re-running it would rewrite the clipboard behind the user's back.
|
|
54
|
+
writeReplay(msg.data);
|
|
55
|
+
scheduleRefresh();
|
|
56
|
+
// The click that got here (a tab, or "+ New session") left the terminal blurred.
|
|
57
|
+
focusTerminal();
|
|
58
|
+
// The PTY was last sized for whichever window had it; this one may be a different shape.
|
|
59
|
+
sendResize();
|
|
60
|
+
if (switching) restoreDraft(msg.sid);
|
|
61
|
+
refreshComposer();
|
|
62
|
+
// The pane and the strip belong to the session, so they move with the screen.
|
|
63
|
+
refreshPane();
|
|
64
|
+
refreshStrip();
|
|
65
|
+
} else if (msg.t === "pages") {
|
|
66
|
+
applyPages(msg);
|
|
67
|
+
} else if (msg.t === "snapshot") {
|
|
68
|
+
applySnapshot(msg);
|
|
69
|
+
} else if (msg.t === "out") {
|
|
70
|
+
// Output from a session this window has already left; the window that has it is showing it.
|
|
71
|
+
if (msg.sid !== attachedSid) return;
|
|
72
|
+
term.write(msg.data);
|
|
73
|
+
scheduleRefresh();
|
|
74
|
+
} else if (msg.t === "busy") {
|
|
75
|
+
takeoverCard(msg.sid);
|
|
76
|
+
} else if (msg.t === "taken") {
|
|
77
|
+
// The session is alive in the other window and can be taken back, so the draft is parked under it
|
|
78
|
+
// rather than thrown away, and comes back with it.
|
|
79
|
+
captureDraft(attachedSid);
|
|
80
|
+
persistDrafts();
|
|
81
|
+
clearComposer();
|
|
82
|
+
setAttachedSid(null);
|
|
83
|
+
takenCard(msg.sid);
|
|
84
|
+
refreshComposer();
|
|
85
|
+
refreshPane();
|
|
86
|
+
refreshStrip();
|
|
87
|
+
} else if (msg.t === "exit") {
|
|
88
|
+
if (msg.sid === attachedSid) {
|
|
89
|
+
setAttachedSid(null);
|
|
90
|
+
// The box was holding an unsent message for a conversation that no longer exists.
|
|
91
|
+
clearComposer();
|
|
92
|
+
term.write(`\r\n\x1b[90m[webterm] ${msg.agent || "the agent"} exited, so this session is gone.\x1b[0m\r\n`);
|
|
93
|
+
shownSid = null;
|
|
94
|
+
shownMessage = EXITED_SCREEN;
|
|
95
|
+
}
|
|
96
|
+
dropDraft(msg.sid);
|
|
97
|
+
dropHistory(msg.sid);
|
|
98
|
+
refreshComposer();
|
|
99
|
+
refreshPane();
|
|
100
|
+
refreshStrip();
|
|
101
|
+
} else if (msg.t === "stopping") {
|
|
102
|
+
// Sent to every window, not just the one that asked: the container is about to take them all.
|
|
103
|
+
stoppingCurtain();
|
|
104
|
+
} else if (msg.t === "error") {
|
|
105
|
+
if (msg.code === "cap") capCard();
|
|
106
|
+
else if (msg.code === "cwd") badDirCard();
|
|
107
|
+
else if (msg.code === "agent") badAgentCard();
|
|
108
|
+
else if (msg.code === "stop") {
|
|
109
|
+
// The container is still here after all, so the page goes back to being usable.
|
|
110
|
+
resumeReconnects();
|
|
111
|
+
hideCurtain();
|
|
112
|
+
stopFailedCard();
|
|
113
|
+
} else noteMsg(`could not start ${msg.agent || "the agent"} - check the webterm log in the container`, true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function applySessions(msg) {
|
|
118
|
+
adoptSessionsFrame(msg);
|
|
119
|
+
// Before the bar draws: its flash reads these timestamps, so an alert plays once instead of on every frame.
|
|
120
|
+
noteArrivals();
|
|
121
|
+
renderBar();
|
|
122
|
+
// Said again where a window that is behind something else can still be heard.
|
|
123
|
+
refreshBrowserTab();
|
|
124
|
+
// And, twenty seconds from now, said out loud - if it is still true and nobody has come back by then.
|
|
125
|
+
scheduleChime();
|
|
126
|
+
// Sessions that are no longer in the bar take their unsent messages and their history with them.
|
|
127
|
+
pruneDrafts();
|
|
128
|
+
pruneHistory();
|
|
129
|
+
refreshComposer();
|
|
130
|
+
// Same for the pane: a session that left the bar takes this window's copy of its page list with it. The
|
|
131
|
+
// files themselves are untouched - a resumed conversation gets them back (FR-15).
|
|
132
|
+
const live = new Set(sessions.map((entry) => entry.id));
|
|
133
|
+
prunePanes(live);
|
|
134
|
+
refreshPane();
|
|
135
|
+
// And for the strip: the numbers belong to a session, and that session is gone.
|
|
136
|
+
pruneStatuses(live);
|
|
137
|
+
refreshStrip();
|
|
138
|
+
// A session can vanish from under this window: closed here, closed from another window, or the agent exited.
|
|
139
|
+
// Where the server had a free session to move this window to, that session's replay has already painted over
|
|
140
|
+
// it. Where it did not - every remaining session is being driven elsewhere - this is the only thing that says
|
|
141
|
+
// the screen in front of the user is dead, and the alternative is a window that looks live and swallows
|
|
142
|
+
// keystrokes. A screen this window is no longer driving but could take back is left alone: that is the
|
|
143
|
+
// takeover case, and its card offers it back.
|
|
144
|
+
if (!attachedSid && shownMessage !== EXITED_SCREEN) {
|
|
145
|
+
const gone = shownSid !== null && !sessions.some((entry) => entry.id === shownSid);
|
|
146
|
+
if (gone || shownSid === null) showMessage(idleMessage());
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Why there is nothing to type into, and the way out of it. Neither line names the session that went away: it
|
|
151
|
+
// is gone from the bar, and what the user needs is the next step.
|
|
152
|
+
function idleMessage() {
|
|
153
|
+
return sessions.length === 0
|
|
154
|
+
? `No sessions. Use "+ New session" above to start ${defaultAgent}.`
|
|
155
|
+
: "Every session is open in another window - click one above to bring it here.";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Replaces the terminal with that line. Only when it changes, so an ordinary bar update does not clear and
|
|
159
|
+
// redraw the screen underneath the user.
|
|
160
|
+
function showMessage(line) {
|
|
161
|
+
if (line === shownMessage) return;
|
|
162
|
+
shownSid = null;
|
|
163
|
+
shownMessage = line;
|
|
164
|
+
term.reset();
|
|
165
|
+
term.write(`\r\n \x1b[90m${line}\x1b[0m\r\n`);
|
|
166
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// main.js - xterm.js terminal, the session bar, and the rich composer, all over one WebSocket.
|
|
2
|
+
//
|
|
3
|
+
// The session bar is mission control for this container: every live agent is a tab, this window drives
|
|
4
|
+
// one of them at a time, and none of them ends because a window closed or a laptop slept. Tabs are dragged
|
|
5
|
+
// to reorder them, and the order is the registry's, so every window agrees on it. A tab also says what its
|
|
6
|
+
// session is doing without being opened: a light travels round it while the agent works, and it holds lit
|
|
7
|
+
// until visited when an agent finished something nobody was there to see - which the page title and the
|
|
8
|
+
// favicon repeat, for when the whole window is behind something else. The terminal renders the
|
|
9
|
+
// attached session's live TUI and forwards raw keystrokes and resizes. The composer handles typing, image
|
|
10
|
+
// paste/drop/upload, and dictation, and what is in it belongs to the attached session: switching tabs swaps
|
|
11
|
+
// the draft, and ending a session throws its draft away. Pasted images are uploaded and their container
|
|
12
|
+
// paths are inserted inline, so what you see in the box is what is sent; on Send the whole composer text
|
|
13
|
+
// goes as one {t:"paste"} frame the server wraps as a bracketed paste.
|
|
14
|
+
//
|
|
15
|
+
// Everything here rides on the key this window was handed in its URL: the socket, the uploads and the
|
|
16
|
+
// status probe all carry it, and without a valid one the page is never served in the first place.
|
|
17
|
+
//
|
|
18
|
+
// The page is plain ES modules with no build step, one per subject:
|
|
19
|
+
//
|
|
20
|
+
// state.js what the server last told this window, and the URL's key
|
|
21
|
+
// dom.js the elements index.html ships with
|
|
22
|
+
// theme.js the palette, this workspace's colour, and the session colours
|
|
23
|
+
// note.js the status pill over the terminal
|
|
24
|
+
// terminal.js the terminal, its size, and the keystrokes that go straight to the PTY
|
|
25
|
+
// tabs.js the session bar, its two animations, renaming, and reordering
|
|
26
|
+
// new-session.js the panel behind the chevron: which agent, and which directory
|
|
27
|
+
// alerts.js the browser tab (title and drawn favicon), and the chime
|
|
28
|
+
// cards.js the overlay cards
|
|
29
|
+
// connection.js the socket, the reconnect loop, and the curtain
|
|
30
|
+
// frames.js what to do with each frame the server sends
|
|
31
|
+
// pane.js the web pane: the chips bar, the drag and the spine
|
|
32
|
+
// pane-frame.js a published page in a sandboxed frame, and the answer that comes back out of it
|
|
33
|
+
// status-strip.js the attached session's model, context, quota and version, above the composer
|
|
34
|
+
// composer.js the box, its images, and Send
|
|
35
|
+
// drafts.js per-session unsent messages, and what was already sent
|
|
36
|
+
// clipboard.js copy and paste, OSC 52, and the replay gate
|
|
37
|
+
// dictation.js the microphone button
|
|
38
|
+
//
|
|
39
|
+
// One rule holds them together: a module defines things and hooks up its own listeners when it is loaded, and
|
|
40
|
+
// never calls into another module while the page is still loading. The graph has cycles on purpose - the frame
|
|
41
|
+
// router talks to every feature and every feature answers back - so a module that ran another module's code at
|
|
42
|
+
// load time would be reaching into something half-built. Everything with an order to it happens here.
|
|
43
|
+
//
|
|
44
|
+
// Reading at load time follows from the same thing: a module may read from one that imports nothing itself
|
|
45
|
+
// (dom.js, state.js, theme.js), since a leaf is always finished by the time anything else runs, and never from
|
|
46
|
+
// one inside the cycle, whose values may not exist yet.
|
|
47
|
+
//
|
|
48
|
+
// Every module is imported here, including the ones this file has nothing to call. Several exist for what they
|
|
49
|
+
// do when they load - the microphone button, the chevron panel's Escape key, the workspace colour, the draft
|
|
50
|
+
// that is saved when the page goes away - and reaching them only through whoever happens to import a function
|
|
51
|
+
// from them would let an unrelated edit take a feature off the page with no test to notice. A test walks the
|
|
52
|
+
// imports from this file and fails if a module is not reachable.
|
|
53
|
+
|
|
54
|
+
import { refreshBrowserTab } from "./alerts.js";
|
|
55
|
+
import { installClipboard } from "./clipboard.js";
|
|
56
|
+
import { refreshComposer } from "./composer.js";
|
|
57
|
+
import { connect } from "./connection.js";
|
|
58
|
+
import "./cards.js";
|
|
59
|
+
import "./dictation.js";
|
|
60
|
+
import "./dom.js";
|
|
61
|
+
import "./drafts.js";
|
|
62
|
+
import "./frames.js";
|
|
63
|
+
import "./new-session.js";
|
|
64
|
+
import "./note.js";
|
|
65
|
+
import "./pane.js";
|
|
66
|
+
import "./pane-frame.js";
|
|
67
|
+
import "./state.js";
|
|
68
|
+
import "./status-strip.js";
|
|
69
|
+
import "./theme.js";
|
|
70
|
+
import { renderBar } from "./tabs.js";
|
|
71
|
+
import { mountTerminal, term } from "./terminal.js";
|
|
72
|
+
|
|
73
|
+
// --- Go ----------------------------------------------------------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
mountTerminal();
|
|
76
|
+
installClipboard();
|
|
77
|
+
refreshComposer();
|
|
78
|
+
renderBar();
|
|
79
|
+
// Before the first frame arrives, so the icon carries this workspace's frame from the moment the page loads.
|
|
80
|
+
refreshBrowserTab();
|
|
81
|
+
connect();
|
|
82
|
+
term.focus();
|