@claudecollab/cli 0.1.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 +109 -0
- package/package.json +36 -0
- package/packages/cli/bin/claude-share.js +1319 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/brain/card.js +157 -0
- package/packages/cli/src/brain/commands.js +122 -0
- package/packages/cli/src/brain/drafts.js +557 -0
- package/packages/cli/src/brain/gate.js +134 -0
- package/packages/cli/src/brain/knocks.js +27 -0
- package/packages/cli/src/brain/log.js +123 -0
- package/packages/cli/src/brain/queue.js +81 -0
- package/packages/cli/src/brain/state.js +245 -0
- package/packages/cli/src/hooks.js +243 -0
- package/packages/cli/src/invite.js +43 -0
- package/packages/cli/src/known-relays.js +66 -0
- package/packages/cli/src/pty.js +175 -0
- package/packages/cli/src/relay-client.js +271 -0
- package/packages/cli/src/renderer.js +230 -0
- package/packages/cli/src/screen-snapshot.js +139 -0
- package/packages/cli/src/term-chatter.js +89 -0
- package/packages/relay/auth.js +18 -0
- package/packages/relay/bin/serve.js +73 -0
- package/packages/relay/names.js +27 -0
- package/packages/relay/package.json +13 -0
- package/packages/relay/public/client.js +1553 -0
- package/packages/relay/public/index.html +110 -0
- package/packages/relay/public/style.css +952 -0
- package/packages/relay/rooms.js +149 -0
- package/packages/relay/server.js +652 -0
- package/packages/relay/web.js +334 -0
- package/packages/relay/wordlists.js +38 -0
- package/packages/shared/package.json +8 -0
- package/packages/shared/protocol.js +189 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// The draft-lines state machine — the shared composer at the heart of the wow
|
|
2
|
+
// moment (spec §draft lines). It is pure logic: raw terminal bytes in, box/cursor
|
|
3
|
+
// state out, no terminal and no I/O. The renderer (Task 7) reads snapshot(); the
|
|
4
|
+
// per-role gate (Task 7) decides who is allowed to feed keystrokes here at all —
|
|
5
|
+
// this module trusts its caller and only models the editor.
|
|
6
|
+
//
|
|
7
|
+
// Model (spec: "a small shared scratchpad, not a single line"):
|
|
8
|
+
// • The pad is an ordered list of BOXES, one per draft.
|
|
9
|
+
// • Each box is a sequence of ATOMS — one atom is either a single character or a
|
|
10
|
+
// collapsed paste token. Paste stays a single atom (one cursor unit) until the
|
|
11
|
+
// draft is sent, then it expands to its real text ("expands only when sent").
|
|
12
|
+
// • Each user has at most one cursor, living in exactly one box (their focus).
|
|
13
|
+
// Moving a cursor into another box = joining it to co-write (Google-Docs style).
|
|
14
|
+
//
|
|
15
|
+
// Key rules the tests pin down:
|
|
16
|
+
// • Enter sends ONLY the box the cursor is in; every other box is untouched.
|
|
17
|
+
// • Racing Enter: the box vanishes on the first send taking all its cursors, so a
|
|
18
|
+
// second Enter from a co-editor finds no focus and no-ops.
|
|
19
|
+
// • An empty draft never sends (nothing queues by accident).
|
|
20
|
+
// • Standard editing keymap: shift+enter newline, word-jump, kill-line, ctrl+w,
|
|
21
|
+
// home/end — decoded from the raw escape bytes a terminal actually emits.
|
|
22
|
+
// • Ctrl+N starts a fresh draft: a participant whose cursor sits in a co-authored
|
|
23
|
+
// box can break out into their own new empty draft (the "+ start a new draft").
|
|
24
|
+
// • Typing "@" at a word boundary opens a mention autocomplete (derived state,
|
|
25
|
+
// read via mentionOf / the keystroke effect; completeMention fills it in).
|
|
26
|
+
|
|
27
|
+
// ── raw-byte vocabulary ─────────────────────────────────────────────────────
|
|
28
|
+
// Bracketed paste brackets its payload; we buffer between these markers (possibly
|
|
29
|
+
// across several keystroke() calls) and collapse the whole thing to one atom.
|
|
30
|
+
const PASTE_START = '\x1b[200~';
|
|
31
|
+
const PASTE_END = '\x1b[201~';
|
|
32
|
+
|
|
33
|
+
// Every recognised key sequence → an internal action. Order is irrelevant: we sort
|
|
34
|
+
// by length descending and take the longest prefix match, so a lone ESC ('\x1b')
|
|
35
|
+
// only wins when no longer escape sequence applies.
|
|
36
|
+
const SEQUENCES = [
|
|
37
|
+
// shift+enter (newline within a draft) — the encodings /terminal-setup produces
|
|
38
|
+
['\x1b\r', 'newline'],
|
|
39
|
+
['\x1b\n', 'newline'],
|
|
40
|
+
['\x1b[13;2u', 'newline'], // kitty keyboard protocol
|
|
41
|
+
['\x1b[27;2;13~', 'newline'], // CSI-u legacy form
|
|
42
|
+
// Enter → send
|
|
43
|
+
['\r', 'enter'],
|
|
44
|
+
['\n', 'enter'],
|
|
45
|
+
// Ctrl+N → start a fresh draft (the "[ + start a new draft ]" affordance): break
|
|
46
|
+
// out of a co-authored box into your own new empty draft (spec §draft lines).
|
|
47
|
+
['\x0e', 'newdraft'],
|
|
48
|
+
// word operations (option/alt + arrow, and ctrl+arrow) and option+backspace
|
|
49
|
+
['\x1bb', 'wordleft'],
|
|
50
|
+
['\x1bf', 'wordright'],
|
|
51
|
+
['\x1b[1;3D', 'wordleft'],
|
|
52
|
+
['\x1b[1;3C', 'wordright'],
|
|
53
|
+
['\x1b[1;5D', 'wordleft'],
|
|
54
|
+
['\x1b[1;5C', 'wordright'],
|
|
55
|
+
['\x1b\x7f', 'delword'], // option/alt + backspace
|
|
56
|
+
['\x17', 'delword'], // ctrl+w
|
|
57
|
+
// deletion
|
|
58
|
+
['\x7f', 'backspace'],
|
|
59
|
+
['\x08', 'backspace'],
|
|
60
|
+
['\x15', 'killstart'], // ctrl+u
|
|
61
|
+
['\x0b', 'killend'], // ctrl+k
|
|
62
|
+
// cursor movement
|
|
63
|
+
['\x1b[H', 'home'],
|
|
64
|
+
['\x1bOH', 'home'],
|
|
65
|
+
['\x1b[1~', 'home'],
|
|
66
|
+
['\x01', 'home'], // ctrl+a
|
|
67
|
+
['\x1b[F', 'end'],
|
|
68
|
+
['\x1bOF', 'end'],
|
|
69
|
+
['\x1b[4~', 'end'],
|
|
70
|
+
['\x05', 'end'], // ctrl+e
|
|
71
|
+
['\x1b[D', 'left'],
|
|
72
|
+
['\x1bOD', 'left'],
|
|
73
|
+
['\x1b[C', 'right'],
|
|
74
|
+
['\x1bOC', 'right'],
|
|
75
|
+
['\x1b[A', 'up'],
|
|
76
|
+
['\x1bOA', 'up'],
|
|
77
|
+
['\x1b[B', 'down'],
|
|
78
|
+
['\x1bOB', 'down'],
|
|
79
|
+
// a lone escape: dismissed (never inserted). Must be last / shortest.
|
|
80
|
+
['\x1b', 'escape'],
|
|
81
|
+
].sort((a, b) => b[0].length - a[0].length);
|
|
82
|
+
|
|
83
|
+
// Characters that count as part of an @mention query (letters, digits, _ and -).
|
|
84
|
+
const MENTION_CHAR = /[\p{L}\p{N}_-]/u;
|
|
85
|
+
const isMentionChar = (atom) => typeof atom === 'string' && MENTION_CHAR.test(atom);
|
|
86
|
+
const isWordChar = (atom) => isMentionChar(atom); // same class for word-jump/delete
|
|
87
|
+
const isSpace = (atom) => typeof atom === 'string' && /\s/.test(atom) && atom !== '\n';
|
|
88
|
+
const isPaste = (atom) => atom != null && typeof atom === 'object' && atom.paste === true;
|
|
89
|
+
|
|
90
|
+
// ── atom → text helpers ──────────────────────────────────────────────────────
|
|
91
|
+
const atomDisplay = (a) => (isPaste(a) ? `[pasted ${a.lines} line${a.lines === 1 ? '' : 's'}]` : a);
|
|
92
|
+
const atomReal = (a) => (isPaste(a) ? a.text : a);
|
|
93
|
+
const displayText = (box) => box.atoms.map(atomDisplay).join('');
|
|
94
|
+
const expandedText = (box) => box.atoms.map(atomReal).join('');
|
|
95
|
+
|
|
96
|
+
// Line boundaries inside a (possibly multi-line) draft, delimited by '\n' atoms.
|
|
97
|
+
function lineStart(atoms, pos) {
|
|
98
|
+
let i = pos;
|
|
99
|
+
while (i > 0 && atoms[i - 1] !== '\n') i--;
|
|
100
|
+
return i;
|
|
101
|
+
}
|
|
102
|
+
function lineEnd(atoms, pos) {
|
|
103
|
+
let i = pos;
|
|
104
|
+
while (i < atoms.length && atoms[i] !== '\n') i++;
|
|
105
|
+
return i;
|
|
106
|
+
}
|
|
107
|
+
// Previous / next word boundary — skip whitespace, then a run of word chars OR a
|
|
108
|
+
// single paste atom (a paste is one indivisible word).
|
|
109
|
+
function wordLeft(atoms, pos) {
|
|
110
|
+
let i = pos;
|
|
111
|
+
while (i > 0 && isSpace(atoms[i - 1])) i--;
|
|
112
|
+
if (i > 0 && isPaste(atoms[i - 1])) return i - 1;
|
|
113
|
+
while (i > 0 && isWordChar(atoms[i - 1])) i--;
|
|
114
|
+
return i;
|
|
115
|
+
}
|
|
116
|
+
function wordRight(atoms, pos) {
|
|
117
|
+
let i = pos;
|
|
118
|
+
while (i < atoms.length && isSpace(atoms[i])) i++;
|
|
119
|
+
if (i < atoms.length && isPaste(atoms[i])) return i + 1;
|
|
120
|
+
while (i < atoms.length && isWordChar(atoms[i])) i++;
|
|
121
|
+
return i;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Split a string into atoms, one per Unicode code point (so astral chars stay whole).
|
|
125
|
+
const toCharAtoms = (str) => [...str];
|
|
126
|
+
|
|
127
|
+
export class Drafts {
|
|
128
|
+
/** @type {{id:string, atoms:Array, cursors:Map<string,number>, authors:Set<string>}[]} */
|
|
129
|
+
boxes = [];
|
|
130
|
+
#seq = 0;
|
|
131
|
+
// Buffered bracketed-paste payload while we wait for PASTE_END; null when idle.
|
|
132
|
+
#pasting = new Map(); // userId -> string buffer
|
|
133
|
+
|
|
134
|
+
// ── public reads ────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/** The box a user's cursor is in, or null. */
|
|
137
|
+
activeBox(userId) {
|
|
138
|
+
for (const b of this.boxes) if (b.cursors.has(userId)) return b;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** {boxId, pos} for a user's cursor, or null. */
|
|
143
|
+
cursorOf(userId) {
|
|
144
|
+
const b = this.activeBox(userId);
|
|
145
|
+
return b ? { boxId: b.id, pos: b.cursors.get(userId) } : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The open @mention autocomplete for a user, or null. Derived from cursor context:
|
|
150
|
+
* open when the cursor sits in a run of mention chars immediately after an "@"
|
|
151
|
+
* that itself starts the box or follows whitespace/newline.
|
|
152
|
+
* @returns {{query:string, start:number, boxId:string}|null}
|
|
153
|
+
*/
|
|
154
|
+
mentionOf(userId) {
|
|
155
|
+
const box = this.activeBox(userId);
|
|
156
|
+
if (!box) return null;
|
|
157
|
+
const pos = box.cursors.get(userId);
|
|
158
|
+
let i = pos;
|
|
159
|
+
while (i > 0 && isMentionChar(box.atoms[i - 1])) i--;
|
|
160
|
+
if (i === 0 || box.atoms[i - 1] !== '@') return null;
|
|
161
|
+
const before = box.atoms[i - 2];
|
|
162
|
+
const atBoundary = i - 1 === 0 || isSpace(before) || before === '\n';
|
|
163
|
+
if (!atBoundary) return null;
|
|
164
|
+
return { query: box.atoms.slice(i, pos).join(''), start: i - 1, boxId: box.id };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Renderer-facing serialisation: plain objects only, no Map/Set. */
|
|
168
|
+
snapshot() {
|
|
169
|
+
return {
|
|
170
|
+
boxes: this.boxes.map((b) => {
|
|
171
|
+
// caretOffsets: each cursor's position as a character offset into the
|
|
172
|
+
// display `text` (paste tokens counted at their collapsed width), so the
|
|
173
|
+
// renderer can place a live caret block without re-deriving atom widths.
|
|
174
|
+
// `cursors` keeps the raw atom index (mention logic / tests depend on it).
|
|
175
|
+
const caretOffsets = {};
|
|
176
|
+
for (const [uid, pos] of b.cursors) {
|
|
177
|
+
caretOffsets[uid] = b.atoms.slice(0, pos).map(atomDisplay).join('').length;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
id: b.id,
|
|
181
|
+
text: displayText(b),
|
|
182
|
+
expanded: expandedText(b),
|
|
183
|
+
cursors: Object.fromEntries(b.cursors),
|
|
184
|
+
caretOffsets,
|
|
185
|
+
authors: [...b.authors],
|
|
186
|
+
place: b.place ?? null,
|
|
187
|
+
};
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── explicit focus commands (used by the renderer / commands wiring) ─────────
|
|
193
|
+
|
|
194
|
+
/** Start a fresh empty draft and focus this user in it; returns the new box id. */
|
|
195
|
+
startDraft(userId) {
|
|
196
|
+
this.#leaveCurrent(userId);
|
|
197
|
+
return this.#attach(userId).id;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Move a user's cursor into an existing draft to co-write it. */
|
|
201
|
+
focus(userId, boxId) {
|
|
202
|
+
const target = this.#boxById(boxId);
|
|
203
|
+
if (!target) return false;
|
|
204
|
+
if (this.activeBox(userId) === target) return true;
|
|
205
|
+
this.#leaveCurrent(userId);
|
|
206
|
+
target.cursors.set(userId, target.atoms.length);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Place a user's caret at a display-text offset inside a draft (a mouse click in
|
|
212
|
+
* a browser tab). The offset is in display characters (the same space as
|
|
213
|
+
* snapshot().caretOffsets); a click inside a collapsed paste token snaps to its
|
|
214
|
+
* nearest edge — a caret can never live inside one atom.
|
|
215
|
+
*/
|
|
216
|
+
placeCaret(userId, boxId, offset) {
|
|
217
|
+
const box = this.#boxById(boxId);
|
|
218
|
+
if (!box) return false;
|
|
219
|
+
if (this.activeBox(userId) !== box) this.#leaveCurrent(userId);
|
|
220
|
+
box.cursors.set(userId, this.#atomIndexAt(box, offset, 'round'));
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Delete a display-offset range from a draft (a browser drag-selection being
|
|
226
|
+
* deleted or typed over). The user's caret lands at the range start; an atom the
|
|
227
|
+
* range only partially covers (a paste token) is deleted whole.
|
|
228
|
+
*/
|
|
229
|
+
deleteRange(userId, boxId, start, end) {
|
|
230
|
+
const box = this.#boxById(boxId);
|
|
231
|
+
if (!box) return false;
|
|
232
|
+
const a = this.#atomIndexAt(box, Math.min(start, end), 'floor');
|
|
233
|
+
const b = this.#atomIndexAt(box, Math.max(start, end), 'ceil');
|
|
234
|
+
if (this.activeBox(userId) !== box) this.#leaveCurrent(userId);
|
|
235
|
+
box.cursors.set(userId, a);
|
|
236
|
+
if (b > a) {
|
|
237
|
+
this.#splice(box, a, b - a, [], userId);
|
|
238
|
+
box.authors.add(userId);
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Display offset → atom index. 'round' snaps by atom midpoint (caret placement);
|
|
244
|
+
// 'floor'/'ceil' bound a range so a partially covered atom is included in it.
|
|
245
|
+
#atomIndexAt(box, offset, mode) {
|
|
246
|
+
const want = Math.max(0, Math.trunc(Number(offset) || 0));
|
|
247
|
+
let seen = 0;
|
|
248
|
+
let pos = 0;
|
|
249
|
+
for (const a of box.atoms) {
|
|
250
|
+
const w = atomDisplay(a).length;
|
|
251
|
+
if (want >= seen + w) {
|
|
252
|
+
seen += w;
|
|
253
|
+
pos += 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (want <= seen) break; // exactly on the boundary → before the atom
|
|
257
|
+
if (mode === 'ceil' || (mode === 'round' && want - seen > w / 2)) pos += 1;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
return pos;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Fill an open @mention with @name plus a trailing space; closes the mention. */
|
|
264
|
+
completeMention(userId, name) {
|
|
265
|
+
const m = this.mentionOf(userId);
|
|
266
|
+
if (!m) return { changed: false };
|
|
267
|
+
const box = this.#boxById(m.boxId);
|
|
268
|
+
const pos = box.cursors.get(userId);
|
|
269
|
+
this.#splice(box, m.start, pos - m.start, toCharAtoms(`@${name} `), userId);
|
|
270
|
+
box.authors.add(userId);
|
|
271
|
+
return { changed: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Move/resize a draft for EVERYONE (the ⠿ drag / ◢ resize are shared, live).
|
|
276
|
+
* Coordinates are fractions of the stage (resolution-independent); null → the
|
|
277
|
+
* home slot above Claude's input line.
|
|
278
|
+
* @param {string} boxId
|
|
279
|
+
* @param {{x:number,y:number,w?:number}|null} spot
|
|
280
|
+
*/
|
|
281
|
+
placeBox(boxId, spot) {
|
|
282
|
+
const box = this.#boxById(boxId);
|
|
283
|
+
if (!box) return false;
|
|
284
|
+
if (spot == null) {
|
|
285
|
+
box.place = null;
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
const f = (v) => Math.max(0, Math.min(1, Number(v) || 0));
|
|
289
|
+
box.place = { x: f(spot.x), y: f(spot.y), ...(spot.w != null ? { w: f(spot.w) } : {}) };
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Start a fresh draft pre-filled with text (a queued prompt pulled back to edit). */
|
|
294
|
+
seedDraft(userId, text) {
|
|
295
|
+
const id = this.startDraft(userId);
|
|
296
|
+
const box = this.#boxById(id);
|
|
297
|
+
this.#splice(box, 0, 0, toCharAtoms(String(text ?? '')), userId);
|
|
298
|
+
box.authors.add(userId);
|
|
299
|
+
return id;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Delete a whole draft box (the ✕ on a draft), cursors and all. */
|
|
303
|
+
deleteBox(boxId) {
|
|
304
|
+
const box = this.#boxById(boxId);
|
|
305
|
+
if (!box) return false;
|
|
306
|
+
this.boxes = this.boxes.filter((b) => b !== box);
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A user left the room: drop their cursor and prune the draft if it's now empty. */
|
|
311
|
+
removeUser(userId) {
|
|
312
|
+
const box = this.activeBox(userId);
|
|
313
|
+
if (!box) return;
|
|
314
|
+
box.cursors.delete(userId);
|
|
315
|
+
this.#prune(box);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── the keystroke pump ───────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Feed one input event (a chunk of raw terminal bytes) from a user.
|
|
322
|
+
* @param {string|Buffer} bytes
|
|
323
|
+
* @returns {{send: null|{text:string, author:string, authors:string[], boxId:string},
|
|
324
|
+
* mention: null|{query:string,start:number,boxId:string},
|
|
325
|
+
* changed: boolean}}
|
|
326
|
+
*/
|
|
327
|
+
keystroke(userId, bytes) {
|
|
328
|
+
const s = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes);
|
|
329
|
+
let send = null;
|
|
330
|
+
let changed = false;
|
|
331
|
+
let i = 0;
|
|
332
|
+
|
|
333
|
+
while (i < s.length) {
|
|
334
|
+
// Mid-paste: swallow bytes until the closing marker (may span calls).
|
|
335
|
+
if (this.#pasting.has(userId)) {
|
|
336
|
+
const end = s.indexOf(PASTE_END, i);
|
|
337
|
+
if (end === -1) {
|
|
338
|
+
this.#pasting.set(userId, this.#pasting.get(userId) + s.slice(i));
|
|
339
|
+
i = s.length;
|
|
340
|
+
} else {
|
|
341
|
+
this.#pasting.set(userId, this.#pasting.get(userId) + s.slice(i, end));
|
|
342
|
+
this.#commitPaste(userId);
|
|
343
|
+
changed = true;
|
|
344
|
+
i = end + PASTE_END.length;
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (s.startsWith(PASTE_START, i)) {
|
|
349
|
+
this.#pasting.set(userId, '');
|
|
350
|
+
i += PASTE_START.length;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const match = matchSequence(s, i);
|
|
355
|
+
if (match) {
|
|
356
|
+
const r = this.#apply(userId, match.type);
|
|
357
|
+
if (r.send) send = r.send;
|
|
358
|
+
if (r.changed) changed = true;
|
|
359
|
+
i += match.seq.length;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A printable code point (keep astral characters whole).
|
|
364
|
+
const cp = s.codePointAt(i);
|
|
365
|
+
const ch = String.fromCodePoint(cp);
|
|
366
|
+
i += ch.length;
|
|
367
|
+
// Unhandled control byte (NUL, tab, stray C0/DEL): drop it, never insert.
|
|
368
|
+
if (cp < 0x20 || cp === 0x7f) continue;
|
|
369
|
+
this.#insert(userId, [ch]);
|
|
370
|
+
changed = true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { send, mention: this.mentionOf(userId), changed };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── key handlers ──────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
#apply(userId, type) {
|
|
379
|
+
switch (type) {
|
|
380
|
+
case 'enter':
|
|
381
|
+
return this.#enter(userId);
|
|
382
|
+
case 'newdraft':
|
|
383
|
+
this.startDraft(userId);
|
|
384
|
+
return { changed: true };
|
|
385
|
+
case 'newline':
|
|
386
|
+
this.#insert(userId, ['\n']);
|
|
387
|
+
return { changed: true };
|
|
388
|
+
case 'backspace':
|
|
389
|
+
return this.#backspace(userId);
|
|
390
|
+
case 'delword':
|
|
391
|
+
return this.#deleteRange(userId, (atoms, pos) => wordLeft(atoms, pos), 'back');
|
|
392
|
+
case 'killstart':
|
|
393
|
+
return this.#deleteRange(userId, (atoms, pos) => lineStart(atoms, pos), 'back');
|
|
394
|
+
case 'killend':
|
|
395
|
+
return this.#deleteRange(userId, (atoms, pos) => lineEnd(atoms, pos), 'fwd');
|
|
396
|
+
case 'home':
|
|
397
|
+
return this.#moveTo(userId, (atoms, pos) => lineStart(atoms, pos));
|
|
398
|
+
case 'end':
|
|
399
|
+
return this.#moveTo(userId, (atoms, pos) => lineEnd(atoms, pos));
|
|
400
|
+
case 'left':
|
|
401
|
+
return this.#moveTo(userId, (atoms, pos) => Math.max(0, pos - 1));
|
|
402
|
+
case 'right':
|
|
403
|
+
return this.#moveTo(userId, (atoms, pos) => Math.min(atoms.length, pos + 1));
|
|
404
|
+
case 'wordleft':
|
|
405
|
+
return this.#moveTo(userId, (atoms, pos) => wordLeft(atoms, pos));
|
|
406
|
+
case 'wordright':
|
|
407
|
+
return this.#moveTo(userId, (atoms, pos) => wordRight(atoms, pos));
|
|
408
|
+
case 'up':
|
|
409
|
+
return { changed: this.#moveBox(userId, -1) };
|
|
410
|
+
case 'down':
|
|
411
|
+
return { changed: this.#moveBox(userId, 1) };
|
|
412
|
+
case 'escape': {
|
|
413
|
+
// Esc steps OUT of the draft (window-like); the box lives on unfocused
|
|
414
|
+
// (an empty one prunes). Esc with no focus never reaches here — the gate
|
|
415
|
+
// routes it to Claude as an interrupt.
|
|
416
|
+
const had = this.activeBox(userId) !== null;
|
|
417
|
+
this.#leaveCurrent(userId);
|
|
418
|
+
return { changed: had };
|
|
419
|
+
}
|
|
420
|
+
default:
|
|
421
|
+
return { changed: false };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
#enter(userId) {
|
|
426
|
+
const box = this.activeBox(userId);
|
|
427
|
+
if (!box || box.atoms.length === 0) return { send: null, changed: false };
|
|
428
|
+
const send = { text: expandedText(box), author: userId, authors: [...box.authors], boxId: box.id };
|
|
429
|
+
this.boxes = this.boxes.filter((b) => b !== box); // the sent draft vanishes, cursors and all
|
|
430
|
+
return { send, changed: true };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#backspace(userId) {
|
|
434
|
+
const box = this.activeBox(userId);
|
|
435
|
+
if (!box) return { changed: false };
|
|
436
|
+
const pos = box.cursors.get(userId);
|
|
437
|
+
if (pos <= 0) return { changed: false };
|
|
438
|
+
this.#splice(box, pos - 1, 1, [], userId);
|
|
439
|
+
box.authors.add(userId);
|
|
440
|
+
return { changed: true };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Delete between the cursor and a computed boundary (backward or forward).
|
|
444
|
+
#deleteRange(userId, boundary, dir) {
|
|
445
|
+
const box = this.activeBox(userId);
|
|
446
|
+
if (!box) return { changed: false };
|
|
447
|
+
const pos = box.cursors.get(userId);
|
|
448
|
+
const edge = boundary(box.atoms, pos);
|
|
449
|
+
const [start, count] = dir === 'back' ? [edge, pos - edge] : [pos, edge - pos];
|
|
450
|
+
if (count <= 0) return { changed: false };
|
|
451
|
+
this.#splice(box, start, count, [], userId);
|
|
452
|
+
box.authors.add(userId);
|
|
453
|
+
return { changed: true };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#moveTo(userId, compute) {
|
|
457
|
+
const box = this.activeBox(userId);
|
|
458
|
+
if (!box) return { changed: false };
|
|
459
|
+
const pos = box.cursors.get(userId);
|
|
460
|
+
const next = compute(box.atoms, pos);
|
|
461
|
+
if (next === pos) return { changed: false };
|
|
462
|
+
box.cursors.set(userId, next);
|
|
463
|
+
return { changed: true };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Up/Down hop the cursor to the neighbouring draft (end of it), joining to co-write.
|
|
467
|
+
#moveBox(userId, dir) {
|
|
468
|
+
const box = this.activeBox(userId);
|
|
469
|
+
if (!box) return false;
|
|
470
|
+
const idx = this.boxes.indexOf(box);
|
|
471
|
+
const nidx = idx + dir;
|
|
472
|
+
if (nidx < 0 || nidx >= this.boxes.length) return false;
|
|
473
|
+
const target = this.boxes[nidx]; // reference captured before any pruning
|
|
474
|
+
box.cursors.delete(userId);
|
|
475
|
+
this.#prune(box);
|
|
476
|
+
target.cursors.set(userId, target.atoms.length);
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── low-level mutation ─────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
#insert(userId, atoms) {
|
|
483
|
+
const box = this.#ensureBox(userId);
|
|
484
|
+
const pos = box.cursors.get(userId);
|
|
485
|
+
this.#splice(box, pos, 0, atoms, userId);
|
|
486
|
+
box.authors.add(userId);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#commitPaste(userId) {
|
|
490
|
+
const raw = this.#pasting.get(userId) ?? '';
|
|
491
|
+
this.#pasting.delete(userId);
|
|
492
|
+
const lines = raw === '' ? 0 : raw.split('\n').length;
|
|
493
|
+
this.#insert(userId, [{ paste: true, text: raw, lines }]);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Splice atoms in a box and re-home every cursor consistently (multi-cursor safe).
|
|
497
|
+
// The acting user's cursor lands just after the inserted atoms; a co-editor cursor
|
|
498
|
+
// strictly before the edit stays put, one after it shifts by the size delta, and
|
|
499
|
+
// one inside a deleted region collapses to the edit start.
|
|
500
|
+
#splice(box, start, deleteCount, insert, actingUser) {
|
|
501
|
+
box.atoms.splice(start, deleteCount, ...insert);
|
|
502
|
+
const ins = insert.length;
|
|
503
|
+
for (const [uid, p] of box.cursors) {
|
|
504
|
+
if (uid === actingUser) continue;
|
|
505
|
+
let np = p;
|
|
506
|
+
if (p <= start) np = p;
|
|
507
|
+
else if (p >= start + deleteCount) np = p + (ins - deleteCount);
|
|
508
|
+
else np = start + ins;
|
|
509
|
+
box.cursors.set(uid, np);
|
|
510
|
+
}
|
|
511
|
+
if (actingUser != null && box.cursors.has(actingUser)) {
|
|
512
|
+
box.cursors.set(actingUser, start + ins);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── box lifecycle ──────────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
#boxById(id) {
|
|
519
|
+
return this.boxes.find((b) => b.id === id) ?? null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#ensureBox(userId) {
|
|
523
|
+
return this.activeBox(userId) ?? this.#attach(userId);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#attach(userId) {
|
|
527
|
+
const box = { id: `d${++this.#seq}`, atoms: [], cursors: new Map([[userId, 0]]), authors: new Set(), place: null };
|
|
528
|
+
// `text` (collapsed display) and `expanded` (real, paste-expanded) are live
|
|
529
|
+
// getters over atoms, so callers and the renderer read them without a method.
|
|
530
|
+
Object.defineProperty(box, 'text', { enumerable: true, get: () => displayText(box) });
|
|
531
|
+
Object.defineProperty(box, 'expanded', { enumerable: true, get: () => expandedText(box) });
|
|
532
|
+
this.boxes.push(box);
|
|
533
|
+
return box;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#leaveCurrent(userId) {
|
|
537
|
+
const cur = this.activeBox(userId);
|
|
538
|
+
if (!cur) return;
|
|
539
|
+
cur.cursors.delete(userId);
|
|
540
|
+
this.#prune(cur);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// A draft with no content and nobody's cursor in it is gone (empty orphan).
|
|
544
|
+
#prune(box) {
|
|
545
|
+
if (box.atoms.length === 0 && box.cursors.size === 0) {
|
|
546
|
+
this.boxes = this.boxes.filter((b) => b !== box);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Longest-prefix match of a recognised key sequence at s[i], or null. */
|
|
552
|
+
function matchSequence(s, i) {
|
|
553
|
+
for (const [seq, type] of SEQUENCES) {
|
|
554
|
+
if (s.startsWith(seq, i)) return { seq, type };
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The per-role input gate — the security boundary for guest keystrokes (spec
|
|
2
|
+
// §per-role input table). Every byte a guest sends over the relay is dispatched
|
|
3
|
+
// here; the host's own stdin is dispatched too (as role 'host') so one table
|
|
4
|
+
// governs everyone. gate.js decides WHERE a keystroke goes; it never performs the
|
|
5
|
+
// action — the wiring executes the returned effect.
|
|
6
|
+
//
|
|
7
|
+
// Two concerns live here:
|
|
8
|
+
// 1. dispatch(userId, role, bytes, ctx) — byte-level routing of a keypress.
|
|
9
|
+
// 2. classifySend / sendAllowed — once a draft is SENT, what is it
|
|
10
|
+
// (prompt vs Claude slash vs bash vs claude-share command) and may this role
|
|
11
|
+
// send it? (Anyone who can prompt may also fire /clear or !ls.)
|
|
12
|
+
//
|
|
13
|
+
// Fail closed: a y/n routes to Claude ONLY when ctx.armed is true (an 'ask'
|
|
14
|
+
// Notification is pending) AND the sender can prompt. If we are not certain
|
|
15
|
+
// an ask is up, a lone "y"/"n" is just a character typed into a draft — even from
|
|
16
|
+
// the host — so a bare keystroke can never leak to Claude by accident. The host
|
|
17
|
+
// typing "yes, ship it" while composing must land in the draft, not answer a
|
|
18
|
+
// non-existent ask (spec §fail-closed: y/n is a permission answer, never a stray
|
|
19
|
+
// draft char).
|
|
20
|
+
|
|
21
|
+
import { atLeast } from './state.js';
|
|
22
|
+
import { COMMAND_NAMES } from './commands.js';
|
|
23
|
+
|
|
24
|
+
const CTRL_C = '\x03';
|
|
25
|
+
const ESC = '\x1b';
|
|
26
|
+
const SHIFT_TAB = '\x1b[Z'; // Claude cycles permission mode on Shift+Tab
|
|
27
|
+
|
|
28
|
+
/** One-time toast a viewer sees the first time their keys are swallowed. */
|
|
29
|
+
export const VIEWER_TOAST = "you're a viewer — ask the host for a role";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Route one input event from a participant.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} userId
|
|
35
|
+
* @param {string} role viewer | prompter | host
|
|
36
|
+
* @param {string|Buffer} bytes raw terminal bytes for this keypress
|
|
37
|
+
* @param {object} [ctx]
|
|
38
|
+
* @param {boolean} [ctx.armed] true iff a permission ask is pending (hook-armed)
|
|
39
|
+
* @param {boolean} [ctx.composing] true iff this user's caret is in a draft box
|
|
40
|
+
* @param {Set<string>} [ctx.toasted] userIds already shown the viewer toast (mutated)
|
|
41
|
+
* @returns {{kind:'draft', bytes:string}
|
|
42
|
+
* |{kind:'pty', data:string}
|
|
43
|
+
* |{kind:'detach'}
|
|
44
|
+
* |{kind:'toast', target:string, message:string}
|
|
45
|
+
* |{kind:'drop'}}
|
|
46
|
+
*/
|
|
47
|
+
export function dispatch(userId, role, bytes, ctx = {}) {
|
|
48
|
+
const s = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes);
|
|
49
|
+
const armed = !!ctx.armed;
|
|
50
|
+
const toasted = ctx.toasted;
|
|
51
|
+
|
|
52
|
+
// Ctrl+C — a guest detaches themselves; the host's is normal terminal input.
|
|
53
|
+
if (s === CTRL_C) {
|
|
54
|
+
return role === 'host' ? { kind: 'pty', data: s } : { kind: 'detach' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Mode flip (Shift+Tab) — prompter and up; silently dropped otherwise. A
|
|
58
|
+
// viewer never even reaches the toast path here (mode flips aren't "typing").
|
|
59
|
+
if (s === SHIFT_TAB) {
|
|
60
|
+
return atLeast(role, 'prompter') ? { kind: 'pty', data: s } : { kind: 'drop' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A lone Escape: composing → step out of the draft (window-like, handled by the
|
|
64
|
+
// editor); not composing → interrupt Claude's current turn, prompter and up
|
|
65
|
+
// (spec: anyone who can prompt can interrupt — now one extra Esc away while
|
|
66
|
+
// composing). A longer escape sequence (arrow keys, etc.) is never an interrupt.
|
|
67
|
+
if (s === ESC) {
|
|
68
|
+
if (ctx.composing) return { kind: 'draft', bytes: s };
|
|
69
|
+
return atLeast(role, 'prompter') ? { kind: 'pty', data: s } : viewerBlock(userId, toasted);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// y/n answering a permission ask — prompter and up, and ONLY while a permission ask
|
|
73
|
+
// is armed (an 'ask' Notification is pending). Fail closed: when we are not certain
|
|
74
|
+
// an ask is up, a lone y/n is ordinary draft input for everyone — the host too — so
|
|
75
|
+
// "yes, ship it" typed while composing is never leaked to Claude as a bare
|
|
76
|
+
// keystroke (spec §fail-closed: y/n is a permission answer, not a stray draft char).
|
|
77
|
+
if (/^[yn]$/i.test(s) && armed && atLeast(role, 'prompter') && !ctx.composing) {
|
|
78
|
+
return { kind: 'pty', data: s };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Everything else is composing in the shared draft pad — prompter and up.
|
|
82
|
+
if (atLeast(role, 'prompter')) return { kind: 'draft', bytes: s };
|
|
83
|
+
|
|
84
|
+
// Viewer (or an unknown role): blocked, with a one-time explanatory toast.
|
|
85
|
+
return viewerBlock(userId, toasted);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A viewer's swallowed keystroke: toast once (if we can track it), then drop.
|
|
89
|
+
function viewerBlock(userId, toasted) {
|
|
90
|
+
if (toasted && !toasted.has(userId)) {
|
|
91
|
+
toasted.add(userId);
|
|
92
|
+
return { kind: 'toast', target: userId, message: VIEWER_TOAST };
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'drop' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Classify a SENT draft's text (spec input table rows: Claude slash-commands, the
|
|
99
|
+
* `!` bash prefix, /recap & the other claude-share commands, plain prompts).
|
|
100
|
+
* @param {string} text
|
|
101
|
+
* @returns {{kind:'command', name:string} | {kind:'claude-slash'} | {kind:'bash'} | {kind:'prompt'}}
|
|
102
|
+
*/
|
|
103
|
+
export function classifySend(text) {
|
|
104
|
+
const t = String(text);
|
|
105
|
+
if (t.startsWith('/')) {
|
|
106
|
+
const name = t.slice(1).split(/\s+/)[0].toLowerCase();
|
|
107
|
+
if (COMMAND_NAMES.has(name)) return { kind: 'command', name };
|
|
108
|
+
return { kind: 'claude-slash' };
|
|
109
|
+
}
|
|
110
|
+
if (t.startsWith('!')) return { kind: 'bash' };
|
|
111
|
+
return { kind: 'prompt' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* May `role` send a draft of this kind? Prompts are prompter+; Claude slash
|
|
116
|
+
* commands and `!` bash too. Command role-gating is per-command and lives
|
|
117
|
+
* in commands.permitted(), so 'command' defers (returns true) here.
|
|
118
|
+
* @param {'command'|'claude-slash'|'bash'|'prompt'} kind
|
|
119
|
+
* @param {string} role
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function sendAllowed(kind, role) {
|
|
123
|
+
switch (kind) {
|
|
124
|
+
case 'prompt':
|
|
125
|
+
return atLeast(role, 'prompter');
|
|
126
|
+
case 'claude-slash':
|
|
127
|
+
case 'bash':
|
|
128
|
+
return atLeast(role, 'prompter');
|
|
129
|
+
case 'command':
|
|
130
|
+
return true;
|
|
131
|
+
default:
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|