@claudecollab/cli 0.1.2 → 0.2.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/README.md +70 -7
- package/package.json +2 -3
- package/packages/cli/bin/claude-share.js +298 -24
- package/packages/cli/src/brain/ask.js +67 -0
- package/packages/cli/src/brain/card.js +7 -3
- package/packages/cli/src/brain/commands.js +5 -1
- package/packages/cli/src/brain/history.js +72 -0
- package/packages/cli/src/brain/log.js +21 -3
- package/packages/cli/src/brain/state.js +24 -2
- package/packages/cli/src/brain/transcript.js +65 -0
- package/packages/cli/src/ctl-client.js +119 -0
- package/packages/cli/src/ctl.js +97 -0
- package/packages/cli/src/first-run.js +166 -0
- package/packages/cli/src/pty.js +38 -0
- package/packages/cli/src/relay-client.js +13 -3
- package/packages/cli/src/room-file.js +22 -0
- package/packages/cli/src/setup-actions.js +163 -0
- package/packages/cli/src/shim.js +125 -0
- package/packages/relay/bin/serve.js +2 -0
- package/packages/relay/public/client.js +206 -41
- package/packages/relay/public/index.html +15 -0
- package/packages/relay/public/style.css +232 -3
- package/packages/relay/rooms.js +5 -2
- package/packages/relay/server.js +34 -3
- package/packages/relay/web.js +102 -24
- package/packages/shared/protocol.js +11 -1
|
@@ -24,6 +24,20 @@ const PASTE_START = '\x1b[200~';
|
|
|
24
24
|
const PASTE_END = '\x1b[201~';
|
|
25
25
|
export const NEW_DRAFT_BYTES = '\x0e'; // Ctrl+N — "start a fresh draft"
|
|
26
26
|
|
|
27
|
+
// The shared-view floor (mirrors FLOOR_COLS/FLOOR_ROWS in brain/state.js). A tab
|
|
28
|
+
// that measures below this is a phone-sized viewer: it reports the sentinel 0×0 so
|
|
29
|
+
// the host scales the mirror onto it instead of shrinking the room or parking it.
|
|
30
|
+
const FLOOR_COLS = 80;
|
|
31
|
+
const FLOOR_ROWS = 24;
|
|
32
|
+
|
|
33
|
+
// The bytes that answer Claude Code's permission ask, sent through the ordinary key
|
|
34
|
+
// channel (the role gate still applies). Real Claude v2 shows a numbered select —
|
|
35
|
+
// option 1 is always "Yes"; Esc is the "(esc)" No option, which denies regardless of
|
|
36
|
+
// how many options the prompt has. (Do NOT use "2" for deny — that is "Yes, and
|
|
37
|
+
// don't ask again", an APPROVE variant.) The fake-claude fixture accepts these too.
|
|
38
|
+
export const ASK_APPROVE_BYTES = '1';
|
|
39
|
+
export const ASK_DENY_BYTES = '\x1b';
|
|
40
|
+
|
|
27
41
|
// ════════════════════════════════════════════════════════════════════════════
|
|
28
42
|
// PURE HELPERS (exported; no DOM, no globals — safe to import in node)
|
|
29
43
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -55,17 +69,17 @@ export function parseLocation(loc) {
|
|
|
55
69
|
|
|
56
70
|
/**
|
|
57
71
|
* Build the `/ws` query the web door expects. A host tab carries `host`; a guest
|
|
58
|
-
* carries its browser `token` (returning identity)
|
|
59
|
-
*
|
|
72
|
+
* carries its browser `token` (returning identity) and claimed `name`.
|
|
73
|
+
* The room password NEVER rides the URL — it is sent as the first WS message in
|
|
74
|
+
* reply to the relay's {t:'pass?'} challenge (proxy/access-log/history leak fix).
|
|
60
75
|
* @returns {string} e.g. "/ws?room=brave-otter&name=sid&token=abc"
|
|
61
76
|
*/
|
|
62
|
-
export function buildWsPath({ code, name, token, hostToken,
|
|
77
|
+
export function buildWsPath({ code, name, token, hostToken, seat } = {}) {
|
|
63
78
|
const q = new URLSearchParams();
|
|
64
79
|
if (code) q.set('room', code);
|
|
65
80
|
if (name) q.set('name', name);
|
|
66
81
|
if (hostToken) q.set('host', hostToken);
|
|
67
82
|
else if (token) q.set('token', token);
|
|
68
|
-
if (pass && !hostToken) q.set('pass', pass);
|
|
69
83
|
// The host-seat secret binds the host seat to THIS browser (leaked-link defense):
|
|
70
84
|
// it never rides the shareable URL — the browser mints it and keeps it locally —
|
|
71
85
|
// so a stolen host link opened elsewhere presents a different seat and is refused.
|
|
@@ -346,6 +360,8 @@ export function overlayView(state, selfId) {
|
|
|
346
360
|
if (b) claimed.add(b.id);
|
|
347
361
|
return b || null;
|
|
348
362
|
};
|
|
363
|
+
// Turn history (who typed what + Claude's response), attributed by author.
|
|
364
|
+
const allTurns = Array.isArray(s.history) ? s.history : [];
|
|
349
365
|
const panels = prompters.map((p) => {
|
|
350
366
|
const box = windowBoxFor(p.id);
|
|
351
367
|
return {
|
|
@@ -359,6 +375,14 @@ export function overlayView(state, selfId) {
|
|
|
359
375
|
text: box?.text ?? '',
|
|
360
376
|
typing: !!(box && (box.text || box.carets.some((c) => c.id === p.id))),
|
|
361
377
|
pending: queue.filter((q) => q.author === p.id).map((q) => ({ n: q.n, text: q.text })),
|
|
378
|
+
turns: allTurns
|
|
379
|
+
.filter((t) => t.author === p.id)
|
|
380
|
+
.map((t) => ({
|
|
381
|
+
id: t.id,
|
|
382
|
+
prompt: t.prompt ?? '',
|
|
383
|
+
response: t.response ?? '',
|
|
384
|
+
running: !!t.running,
|
|
385
|
+
})),
|
|
362
386
|
};
|
|
363
387
|
});
|
|
364
388
|
const floatingDrafts = drafts.filter((d) => !claimed.has(d.id));
|
|
@@ -377,6 +401,11 @@ export function overlayView(state, selfId) {
|
|
|
377
401
|
queue,
|
|
378
402
|
claudeState: s.claudeState || 'idle',
|
|
379
403
|
claude: claudeLabel(s.claudeState),
|
|
404
|
+
// What Claude is asking permission for (only present while claudeState==='ask').
|
|
405
|
+
ask:
|
|
406
|
+
s.ask && typeof s.ask === 'object'
|
|
407
|
+
? { tool: String(s.ask.tool || 'tool'), summary: String(s.ask.summary || '') }
|
|
408
|
+
: null,
|
|
380
409
|
paused: !!s.paused,
|
|
381
410
|
knocks,
|
|
382
411
|
// The latest addressed notice ({id, msg, seq}) — shown as a toast when it's mine.
|
|
@@ -500,6 +529,13 @@ function main() {
|
|
|
500
529
|
const newDraftBtn = $('#new-draft');
|
|
501
530
|
const hostControls = $('#host-controls');
|
|
502
531
|
const knocksBox = $('#knocks');
|
|
532
|
+
const askCard = $('#ask-card');
|
|
533
|
+
const askTool = $('#ask-tool');
|
|
534
|
+
const askSummary = $('#ask-summary');
|
|
535
|
+
const askActions = $('#ask-actions');
|
|
536
|
+
const askApprove = $('#ask-approve');
|
|
537
|
+
const askDeny = $('#ask-deny');
|
|
538
|
+
const askViewnote = $('#ask-viewnote');
|
|
503
539
|
const avatarsBox = $('#avatars');
|
|
504
540
|
const popover = $('#popover');
|
|
505
541
|
const copyInviteBtn = $('#copy-invite');
|
|
@@ -547,12 +583,14 @@ function main() {
|
|
|
547
583
|
timeout: 'The host did not answer in time. Try again when they are around.',
|
|
548
584
|
denied: 'The host declined your request.',
|
|
549
585
|
closed: 'The connection closed before you were let in — try again.',
|
|
586
|
+
busy: 'Too many people are waiting to join — try again in a minute.',
|
|
550
587
|
// password gets its own copy in fail(): the message depends on whether we
|
|
551
588
|
// had one to send ("needs a password" vs "wrong password").
|
|
552
589
|
password: 'This room is password-protected.',
|
|
553
590
|
};
|
|
554
591
|
|
|
555
592
|
let sentPass = null; // what the last attempt presented (drives the fail() copy)
|
|
593
|
+
let pendingPass = ''; // the password to send when the relay challenges with {t:'pass?'}
|
|
556
594
|
|
|
557
595
|
function beginKnock() {
|
|
558
596
|
const code = (loc.code || codeInput.value || '').trim();
|
|
@@ -578,6 +616,9 @@ function main() {
|
|
|
578
616
|
|
|
579
617
|
// ── connection ───────────────────────────────────────────────────────────────
|
|
580
618
|
function connect({ code, name, token, hostToken, pass, seat }) {
|
|
619
|
+
// Stash the password to answer the relay's {t:'pass?'} challenge — it rides the
|
|
620
|
+
// wire, never the URL. Host tabs never present one.
|
|
621
|
+
pendingPass = hostToken ? '' : pass || '';
|
|
581
622
|
// Supersede any previous socket COMPLETELY. A zombie from an earlier attempt
|
|
582
623
|
// still has live handlers — when the brain dedupes its knock, its deny/close
|
|
583
624
|
// would splash "declined" into a UI that belongs to the NEW attempt.
|
|
@@ -595,7 +636,7 @@ function main() {
|
|
|
595
636
|
? 'Opening your room…'
|
|
596
637
|
: `Waiting for the host to let you in…`;
|
|
597
638
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
598
|
-
const url = proto + '//' + location.host + buildWsPath({ code, name, token, hostToken,
|
|
639
|
+
const url = proto + '//' + location.host + buildWsPath({ code, name, token, hostToken, seat });
|
|
599
640
|
let sock;
|
|
600
641
|
try {
|
|
601
642
|
sock = new WebSocket(url);
|
|
@@ -638,6 +679,12 @@ function main() {
|
|
|
638
679
|
}
|
|
639
680
|
if (!msg || typeof msg !== 'object') return;
|
|
640
681
|
switch (msg.t) {
|
|
682
|
+
case 'pass?':
|
|
683
|
+
// The relay is challenging for the room password over the wire (never the
|
|
684
|
+
// URL). Answer with the stored/typed value; a wrong/empty one comes back as
|
|
685
|
+
// an {t:'error',reason:'password'} that reveals the field.
|
|
686
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t: 'pass', pass: pendingPass }));
|
|
687
|
+
break;
|
|
641
688
|
case 'joined':
|
|
642
689
|
// A host tab IS the host (the brain maps it to HOST_ID and never adds it as a
|
|
643
690
|
// second participant — finding 4), so adopt the host's roster identity: the
|
|
@@ -787,6 +834,19 @@ function main() {
|
|
|
787
834
|
// as a dead mouse report (stdin is disabled). Capture it before xterm and mail
|
|
788
835
|
// it to the brain, which types the real report into the pty.
|
|
789
836
|
stage.addEventListener('wheel', onStageWheel, { passive: false, capture: true });
|
|
837
|
+
// Copy Claude's output: xterm owns selection inside the mirror (it suppresses
|
|
838
|
+
// the native DOM selection there), so on Cmd/Ctrl+C with no page-text selected,
|
|
839
|
+
// put xterm's selection on the clipboard. A real DOM selection — a prompt or
|
|
840
|
+
// response highlighted in a window box — copies natively and is left alone.
|
|
841
|
+
document.addEventListener('copy', (e) => {
|
|
842
|
+
if (phase !== 'live' || !term) return;
|
|
843
|
+
const domSel = window.getSelection();
|
|
844
|
+
if (domSel && !domSel.isCollapsed && String(domSel).trim()) return;
|
|
845
|
+
if (term.hasSelection?.()) {
|
|
846
|
+
e.clipboardData?.setData('text/plain', term.getSelection());
|
|
847
|
+
e.preventDefault();
|
|
848
|
+
}
|
|
849
|
+
});
|
|
790
850
|
}
|
|
791
851
|
|
|
792
852
|
let wheelAcc = 0;
|
|
@@ -839,7 +899,11 @@ function main() {
|
|
|
839
899
|
const { cw, ch } = refCellSize();
|
|
840
900
|
const cols = Math.max(2, Math.floor((rect.width - 24) / cw));
|
|
841
901
|
const rows = Math.max(1, Math.floor((rect.height - 20) / ch));
|
|
842
|
-
|
|
902
|
+
// Below the shared floor (a phone): report the sentinel 0×0 — "I scale, ignore my
|
|
903
|
+
// size" — so the host neither shrinks the room to us nor parks us on a hint. We
|
|
904
|
+
// keep rendering state.view zoomed (scaleTerm). Report real capacity otherwise.
|
|
905
|
+
const scaled = cols < FLOOR_COLS || rows < FLOOR_ROWS;
|
|
906
|
+
sendMsg({ t: 'resize', cols: scaled ? 0 : cols, rows: scaled ? 0 : rows });
|
|
843
907
|
scaleTerm();
|
|
844
908
|
}
|
|
845
909
|
|
|
@@ -1047,6 +1111,13 @@ function main() {
|
|
|
1047
1111
|
setTimeout(() => composer.focus(), 0); // keys land in the new shared box
|
|
1048
1112
|
});
|
|
1049
1113
|
|
|
1114
|
+
// Approve / Deny a permission ask: exactly the keystrokes a person at the terminal
|
|
1115
|
+
// would press, through the ordinary key channel — no new privileged action, so the
|
|
1116
|
+
// host's role gate (prompter+) applies untouched. Real Claude answers a numbered
|
|
1117
|
+
// select: "1" = Yes, Esc = the "(esc)" No option (see ASK_APPROVE/DENY_BYTES).
|
|
1118
|
+
askApprove.addEventListener('click', () => sendKey(ASK_APPROVE_BYTES));
|
|
1119
|
+
askDeny.addEventListener('click', () => sendKey(ASK_DENY_BYTES));
|
|
1120
|
+
|
|
1050
1121
|
// ── host controls ────────────────────────────────────────────────────────────
|
|
1051
1122
|
// Copy the SAFE, token-free invite link — never this tab's own host URL (finding 1).
|
|
1052
1123
|
copyInviteBtn?.addEventListener('click', async () => {
|
|
@@ -1105,18 +1176,18 @@ function main() {
|
|
|
1105
1176
|
endConfirm.append(el('span', null, 'Save session.md?'));
|
|
1106
1177
|
const save = el('button', 'kbtn yes', 'Save & end');
|
|
1107
1178
|
const skip = el('button', 'kbtn no', 'Just end');
|
|
1108
|
-
// The
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
save.onclick = () => finishEnd();
|
|
1112
|
-
skip.onclick = () => finishEnd();
|
|
1179
|
+
// The two-step gate lives here so the host never has to touch the terminal.
|
|
1180
|
+
// "Save & end" writes session.md (/end); "Just end" writes nothing
|
|
1181
|
+
// (/end nosave) so it can't overwrite an existing file. Both end the room.
|
|
1182
|
+
save.onclick = () => finishEnd(true);
|
|
1183
|
+
skip.onclick = () => finishEnd(false);
|
|
1113
1184
|
endConfirm.append(save, skip);
|
|
1114
1185
|
}
|
|
1115
1186
|
}
|
|
1116
|
-
function finishEnd() {
|
|
1187
|
+
function finishEnd(save) {
|
|
1117
1188
|
endStep = 0;
|
|
1118
1189
|
renderEndConfirm();
|
|
1119
|
-
sendCommand('/end');
|
|
1190
|
+
sendCommand(save ? '/end' : '/end nosave');
|
|
1120
1191
|
}
|
|
1121
1192
|
|
|
1122
1193
|
// ── render ─────────────────────────────────────────────────────────────────
|
|
@@ -1158,6 +1229,7 @@ function main() {
|
|
|
1158
1229
|
ghostHint(v);
|
|
1159
1230
|
applyView(v);
|
|
1160
1231
|
renderStatusbar(v);
|
|
1232
|
+
renderAsk(v);
|
|
1161
1233
|
renderClaudeChip(v);
|
|
1162
1234
|
renderPaused(v);
|
|
1163
1235
|
renderPanels(v);
|
|
@@ -1194,6 +1266,23 @@ function main() {
|
|
|
1194
1266
|
sbRole.append(b);
|
|
1195
1267
|
}
|
|
1196
1268
|
|
|
1269
|
+
// The permission ask card — the touch-first approve/deny surface (all widths, the
|
|
1270
|
+
// one thing a phone most needs). Approve/Deny are ORDINARY keystrokes sent through
|
|
1271
|
+
// the key channel (the host's role gate applies); only people who may type see the
|
|
1272
|
+
// buttons, viewers see the card without them. The old amber pulse stays; its
|
|
1273
|
+
// "click to answer" pill is suppressed while the card is up (see .has-ask-card).
|
|
1274
|
+
function renderAsk(v) {
|
|
1275
|
+
const show = v.claudeState === 'ask' && !!v.ask;
|
|
1276
|
+
askCard.hidden = !show;
|
|
1277
|
+
stage.classList.toggle('has-ask-card', show);
|
|
1278
|
+
if (!show) return;
|
|
1279
|
+
askTool.textContent = v.ask.tool || 'tool';
|
|
1280
|
+
askSummary.textContent = v.ask.summary || '';
|
|
1281
|
+
askSummary.hidden = !v.ask.summary;
|
|
1282
|
+
askActions.hidden = !v.canCompose; // buttons only for prompter+ (role gate mirrors this)
|
|
1283
|
+
askViewnote.hidden = v.canCompose; // viewers see the card, but no buttons
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1197
1286
|
function renderClaudeChip(v) {
|
|
1198
1287
|
claudeChip.className = 'chip claude ' + v.claude.kind;
|
|
1199
1288
|
claudeChip.textContent = v.claude.text;
|
|
@@ -1229,9 +1318,11 @@ function main() {
|
|
|
1229
1318
|
|
|
1230
1319
|
// ── per-user windows: everyone's own space to type a prompt SEPARATELY ────────
|
|
1231
1320
|
// The panel DOM is rebuilt only when the roster changes; the dynamic bits (draft
|
|
1232
|
-
// text, status,
|
|
1321
|
+
// text, status, history) update in place so a focused input never loses focus.
|
|
1233
1322
|
let panelsKey = '';
|
|
1234
|
-
const panelEls = new Map(); // participant id -> { input, body, status }
|
|
1323
|
+
const panelEls = new Map(); // participant id -> { input, body, status, bodyKey }
|
|
1324
|
+
const expandedTurns = new Set(); // turn ids the viewer expanded to full response
|
|
1325
|
+
const RESP_LIMIT = 280; // chars of a response shown before "show more"
|
|
1235
1326
|
function renderPanels(v) {
|
|
1236
1327
|
const key = v.panels.map((p) => `${p.id}:${p.name}:${p.color}:${p.canType}`).join('|');
|
|
1237
1328
|
if (key !== panelsKey) {
|
|
@@ -1259,14 +1350,16 @@ function main() {
|
|
|
1259
1350
|
wrap.append(input);
|
|
1260
1351
|
win.append(head, body, wrap);
|
|
1261
1352
|
panelsGrid.append(win);
|
|
1262
|
-
panelEls.set(p.id, { input, body, status });
|
|
1353
|
+
panelEls.set(p.id, { input, body, status, bodyKey: null });
|
|
1263
1354
|
}
|
|
1264
1355
|
}
|
|
1265
1356
|
for (const p of v.panels) {
|
|
1266
1357
|
const e = panelEls.get(p.id);
|
|
1267
1358
|
if (!e) continue;
|
|
1268
|
-
|
|
1359
|
+
const running = p.turns.some((t) => t.running);
|
|
1360
|
+
e.status.textContent = p.typing ? 'typing…' : running ? 'waiting…' : 'idle';
|
|
1269
1361
|
e.status.classList.toggle('typing', p.typing);
|
|
1362
|
+
e.status.classList.toggle('waiting', !p.typing && running);
|
|
1270
1363
|
// mirror the live draft into the input — the brain is the authority, so we
|
|
1271
1364
|
// never echo locally; setting value keeps every tab showing the same text.
|
|
1272
1365
|
if (e.input.value !== p.text) {
|
|
@@ -1281,41 +1374,110 @@ function main() {
|
|
|
1281
1374
|
}
|
|
1282
1375
|
}
|
|
1283
1376
|
}
|
|
1284
|
-
e
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1377
|
+
renderPanelBody(e, p);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Render one window's body: attributed history (prompt + Claude's response, with
|
|
1382
|
+
// a show-more toggle) then any still-queued prompts. Diff-guarded so an unrelated
|
|
1383
|
+
// state frame (a pointer move) never rebuilds it mid-selection and kills a copy.
|
|
1384
|
+
function renderPanelBody(e, p) {
|
|
1385
|
+
const expandedHere = p.turns.filter((t) => expandedTurns.has(t.id)).map((t) => t.id);
|
|
1386
|
+
const key = JSON.stringify({ turns: p.turns, pending: p.pending, expandedHere, self: p.isSelf });
|
|
1387
|
+
if (key === e.bodyKey) return;
|
|
1388
|
+
// Don't clobber an active selection inside this body (the viewer is copying).
|
|
1389
|
+
const sel = window.getSelection();
|
|
1390
|
+
if (e.bodyKey !== null && sel && !sel.isCollapsed && e.body.contains(sel.anchorNode)) return;
|
|
1391
|
+
// "stick to bottom" only when already near the bottom before the rebuild.
|
|
1392
|
+
const stick = e.bodyKey === null || e.body.scrollHeight - e.body.scrollTop - e.body.clientHeight < 40;
|
|
1393
|
+
e.bodyKey = key;
|
|
1394
|
+
e.body.innerHTML = '';
|
|
1395
|
+
|
|
1396
|
+
if (p.turns.length === 0 && p.pending.length === 0) {
|
|
1397
|
+
e.body.append(
|
|
1398
|
+
el('div', 'uw-empty', p.isSelf ? 'Your prompts and Claude’s replies show here. Enter to send.' : 'No prompts yet.'),
|
|
1399
|
+
);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
for (const t of p.turns) {
|
|
1404
|
+
const turn = el('div', 'uw-turn');
|
|
1405
|
+
const promptLine = el('div', 'uw-line uw-prompt');
|
|
1406
|
+
promptLine.append(el('span', 'uw-caret', '›'), document.createTextNode(' ' + t.prompt));
|
|
1407
|
+
turn.append(promptLine);
|
|
1408
|
+
|
|
1409
|
+
if (t.running) {
|
|
1410
|
+
turn.append(el('div', 'uw-resp running', 'Claude is working…'));
|
|
1411
|
+
} else if (t.response) {
|
|
1412
|
+
const resp = el('div', 'uw-resp');
|
|
1413
|
+
const long = t.response.length > RESP_LIMIT;
|
|
1414
|
+
const expanded = expandedTurns.has(t.id);
|
|
1415
|
+
const shown = long && !expanded ? t.response.slice(0, RESP_LIMIT).trimEnd() + '…' : t.response;
|
|
1416
|
+
resp.append(document.createTextNode(shown));
|
|
1417
|
+
if (long) {
|
|
1418
|
+
const more = el('button', 'uw-more', expanded ? 'show less' : 'show more');
|
|
1419
|
+
more.type = 'button';
|
|
1420
|
+
more.onclick = () => {
|
|
1421
|
+
if (expandedTurns.has(t.id)) expandedTurns.delete(t.id);
|
|
1422
|
+
else expandedTurns.add(t.id);
|
|
1423
|
+
render();
|
|
1424
|
+
};
|
|
1425
|
+
resp.append(more);
|
|
1294
1426
|
}
|
|
1427
|
+
turn.append(resp);
|
|
1295
1428
|
}
|
|
1429
|
+
e.body.append(turn);
|
|
1296
1430
|
}
|
|
1431
|
+
|
|
1432
|
+
for (const q of p.pending) {
|
|
1433
|
+
const line = el('div', 'uw-line uw-queued');
|
|
1434
|
+
line.append(el('span', 'uw-n', '#' + q.n), document.createTextNode(' ' + q.text));
|
|
1435
|
+
line.append(el('span', 'uw-badge', 'queued'));
|
|
1436
|
+
e.body.append(line);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if (stick) e.body.scrollTop = e.body.scrollHeight;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// The host routes a prompter's keys straight to Claude whenever they have no
|
|
1443
|
+
// draft box open ("you're at the terminal"). A window input must therefore
|
|
1444
|
+
// GUARANTEE a box exists before its keystrokes land, or the text leaks into
|
|
1445
|
+
// Claude (and is never recorded as that person's turn). After each send the box
|
|
1446
|
+
// is consumed, so we re-arm on the next keystroke. `draftArmed` bridges the state
|
|
1447
|
+
// round-trip so we never spawn a second empty box while the first is in flight.
|
|
1448
|
+
let draftArmed = false;
|
|
1449
|
+
function myWindowBox(v) {
|
|
1450
|
+
const mine = v?.panels.find((p) => p.isSelf);
|
|
1451
|
+
if (!mine?.boxId) return null;
|
|
1452
|
+
return v.drafts.find((d) => d.id === mine.boxId && !d.place) || null;
|
|
1453
|
+
}
|
|
1454
|
+
function ensureMyDraft() {
|
|
1455
|
+
const v = lastState ? overlayView(lastState, selfId) : null;
|
|
1456
|
+
const box = myWindowBox(v);
|
|
1457
|
+
if (box) {
|
|
1458
|
+
draftArmed = false;
|
|
1459
|
+
// keep my caret in my own window box (not a floating box I also co-wrote)
|
|
1460
|
+
if (!box.carets.some((c) => c.self)) {
|
|
1461
|
+
sendMsg({ t: 'ui', action: { kind: 'caret', id: box.id, offset: box.text.length } });
|
|
1462
|
+
}
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (draftArmed) return; // already asked for one; it just hasn't echoed back yet
|
|
1466
|
+
sendKey(NEW_DRAFT_BYTES);
|
|
1467
|
+
draftArmed = true;
|
|
1297
1468
|
}
|
|
1298
1469
|
|
|
1299
1470
|
// Wire a self window input: keys route to the brain (which owns the draft), so the
|
|
1300
1471
|
// input is a controlled mirror of my private box. Enter sends it into the queue.
|
|
1301
1472
|
function wirePanelInput(input) {
|
|
1302
|
-
input.addEventListener('focus', () =>
|
|
1303
|
-
const v = lastState ? overlayView(lastState, selfId) : null;
|
|
1304
|
-
const mine = v?.panels.find((p) => p.isSelf);
|
|
1305
|
-
if (!mine) return;
|
|
1306
|
-
if (mine.boxId) {
|
|
1307
|
-
// make sure my caret sits in my window box (not a floating box I co-wrote)
|
|
1308
|
-
const box = v.drafts.find((d) => d.id === mine.boxId);
|
|
1309
|
-
const caretHere = box?.carets.some((c) => c.self);
|
|
1310
|
-
if (!caretHere) sendMsg({ t: 'ui', action: { kind: 'caret', id: mine.boxId, offset: mine.text.length } });
|
|
1311
|
-
} else {
|
|
1312
|
-
sendKey(NEW_DRAFT_BYTES); // no private draft yet → start one
|
|
1313
|
-
}
|
|
1314
|
-
});
|
|
1473
|
+
input.addEventListener('focus', () => ensureMyDraft());
|
|
1315
1474
|
input.addEventListener('keydown', (e) => {
|
|
1316
1475
|
const bytes = keyToBytes(e); // Enter → send, editing chords, printable chars
|
|
1317
1476
|
if (bytes != null) {
|
|
1318
1477
|
e.preventDefault();
|
|
1478
|
+
// Ordered on the wire: the new-draft byte lands before this key, so the key
|
|
1479
|
+
// always composes into my box instead of leaking to Claude.
|
|
1480
|
+
if (e.key !== 'Escape') ensureMyDraft();
|
|
1319
1481
|
sendKey(bytes);
|
|
1320
1482
|
return;
|
|
1321
1483
|
}
|
|
@@ -1325,7 +1487,10 @@ function main() {
|
|
|
1325
1487
|
input.addEventListener('paste', (e) => {
|
|
1326
1488
|
e.preventDefault();
|
|
1327
1489
|
const text = (e.clipboardData || window.clipboardData)?.getData('text') || '';
|
|
1328
|
-
if (text)
|
|
1490
|
+
if (text) {
|
|
1491
|
+
ensureMyDraft();
|
|
1492
|
+
sendKey(pasteBytes(text));
|
|
1493
|
+
}
|
|
1329
1494
|
});
|
|
1330
1495
|
// never let a local echo diverge from the brain's authoritative text
|
|
1331
1496
|
input.addEventListener('input', () => {
|
|
@@ -85,6 +85,21 @@
|
|
|
85
85
|
</div>
|
|
86
86
|
|
|
87
87
|
<main class="workspace">
|
|
88
|
+
<!-- permission ask: the one thing a phone most needs to answer. Big Approve/
|
|
89
|
+
Deny buttons; on desktop it floats over the terminal, on narrow it leads
|
|
90
|
+
the column. Buttons send ordinary keystrokes (the role gate still applies). -->
|
|
91
|
+
<div id="ask-card" class="ask-card" hidden>
|
|
92
|
+
<div class="ask-head">
|
|
93
|
+
<span class="ask-badge">permission</span>
|
|
94
|
+
<span>Claude wants to use <b id="ask-tool">tool</b></span>
|
|
95
|
+
</div>
|
|
96
|
+
<div id="ask-summary" class="ask-summary" hidden></div>
|
|
97
|
+
<div id="ask-actions" class="ask-actions" hidden>
|
|
98
|
+
<button id="ask-approve" class="ask-btn yes" type="button">Approve</button>
|
|
99
|
+
<button id="ask-deny" class="ask-btn no" type="button">Deny</button>
|
|
100
|
+
</div>
|
|
101
|
+
<div id="ask-viewnote" class="ask-viewnote" hidden>waiting for someone who can type to answer</div>
|
|
102
|
+
</div>
|
|
88
103
|
<!-- Claude's live session: the shared terminal everyone watches respond.
|
|
89
104
|
Floating "+ draft" boxes (write a prompt TOGETHER) overlay it. -->
|
|
90
105
|
<div id="stage">
|