clay-server 2.5.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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +281 -0
  3. package/bin/cli.js +2385 -0
  4. package/lib/cli-sessions.js +270 -0
  5. package/lib/config.js +237 -0
  6. package/lib/daemon.js +489 -0
  7. package/lib/ipc.js +112 -0
  8. package/lib/notes.js +120 -0
  9. package/lib/pages.js +664 -0
  10. package/lib/project.js +1433 -0
  11. package/lib/public/app.js +2795 -0
  12. package/lib/public/apple-touch-icon-dark.png +0 -0
  13. package/lib/public/apple-touch-icon.png +0 -0
  14. package/lib/public/css/base.css +264 -0
  15. package/lib/public/css/diff.css +128 -0
  16. package/lib/public/css/filebrowser.css +1114 -0
  17. package/lib/public/css/highlight.css +144 -0
  18. package/lib/public/css/icon-strip.css +296 -0
  19. package/lib/public/css/input.css +573 -0
  20. package/lib/public/css/menus.css +856 -0
  21. package/lib/public/css/messages.css +1445 -0
  22. package/lib/public/css/mobile-nav.css +354 -0
  23. package/lib/public/css/overlays.css +697 -0
  24. package/lib/public/css/rewind.css +505 -0
  25. package/lib/public/css/server-settings.css +761 -0
  26. package/lib/public/css/sidebar.css +936 -0
  27. package/lib/public/css/sticky-notes.css +358 -0
  28. package/lib/public/css/title-bar.css +314 -0
  29. package/lib/public/favicon-dark.svg +1 -0
  30. package/lib/public/favicon.svg +1 -0
  31. package/lib/public/icon-192-dark.png +0 -0
  32. package/lib/public/icon-192.png +0 -0
  33. package/lib/public/icon-512-dark.png +0 -0
  34. package/lib/public/icon-512.png +0 -0
  35. package/lib/public/icon-mono.svg +1 -0
  36. package/lib/public/index.html +762 -0
  37. package/lib/public/manifest.json +27 -0
  38. package/lib/public/modules/diff.js +398 -0
  39. package/lib/public/modules/events.js +21 -0
  40. package/lib/public/modules/filebrowser.js +1411 -0
  41. package/lib/public/modules/fileicons.js +172 -0
  42. package/lib/public/modules/icons.js +54 -0
  43. package/lib/public/modules/input.js +584 -0
  44. package/lib/public/modules/markdown.js +356 -0
  45. package/lib/public/modules/notifications.js +649 -0
  46. package/lib/public/modules/qrcode.js +70 -0
  47. package/lib/public/modules/rewind.js +345 -0
  48. package/lib/public/modules/server-settings.js +510 -0
  49. package/lib/public/modules/sidebar.js +1083 -0
  50. package/lib/public/modules/state.js +3 -0
  51. package/lib/public/modules/sticky-notes.js +688 -0
  52. package/lib/public/modules/terminal.js +697 -0
  53. package/lib/public/modules/theme.js +738 -0
  54. package/lib/public/modules/tools.js +1608 -0
  55. package/lib/public/modules/utils.js +56 -0
  56. package/lib/public/style.css +15 -0
  57. package/lib/public/sw.js +75 -0
  58. package/lib/push.js +124 -0
  59. package/lib/sdk-bridge.js +989 -0
  60. package/lib/server.js +582 -0
  61. package/lib/sessions.js +424 -0
  62. package/lib/terminal-manager.js +187 -0
  63. package/lib/terminal.js +24 -0
  64. package/lib/themes/ayu-light.json +9 -0
  65. package/lib/themes/catppuccin-latte.json +9 -0
  66. package/lib/themes/catppuccin-mocha.json +9 -0
  67. package/lib/themes/clay-light.json +10 -0
  68. package/lib/themes/clay.json +10 -0
  69. package/lib/themes/dracula.json +9 -0
  70. package/lib/themes/everforest-light.json +9 -0
  71. package/lib/themes/everforest.json +9 -0
  72. package/lib/themes/github-light.json +9 -0
  73. package/lib/themes/gruvbox-dark.json +9 -0
  74. package/lib/themes/gruvbox-light.json +9 -0
  75. package/lib/themes/monokai.json +9 -0
  76. package/lib/themes/nord-light.json +9 -0
  77. package/lib/themes/nord.json +9 -0
  78. package/lib/themes/one-dark.json +9 -0
  79. package/lib/themes/one-light.json +9 -0
  80. package/lib/themes/rose-pine-dawn.json +9 -0
  81. package/lib/themes/rose-pine.json +9 -0
  82. package/lib/themes/solarized-dark.json +9 -0
  83. package/lib/themes/solarized-light.json +9 -0
  84. package/lib/themes/tokyo-night-light.json +9 -0
  85. package/lib/themes/tokyo-night.json +9 -0
  86. package/lib/updater.js +97 -0
  87. package/package.json +47 -0
@@ -0,0 +1,584 @@
1
+ import { iconHtml, refreshIcons } from './icons.js';
2
+ import { setRewindMode, isRewindMode } from './rewind.js';
3
+
4
+ var ctx;
5
+
6
+ // --- State ---
7
+ var pendingImages = []; // [{data: base64, mediaType: "image/png"}]
8
+ var pendingPastes = []; // [{text: string, preview: string}]
9
+ var slashActiveIdx = -1;
10
+ var slashFiltered = [];
11
+ var isComposing = false;
12
+ var isRemoteInput = false;
13
+
14
+ export var builtinCommands = [
15
+ { name: "clear", desc: "Clear conversation" },
16
+ { name: "context", desc: "Context window usage" },
17
+ { name: "rewind", desc: "Toggle rewind mode" },
18
+ { name: "usage", desc: "Toggle usage panel" },
19
+ { name: "status", desc: "Process status and resource usage" },
20
+ ];
21
+
22
+ // --- Send ---
23
+ export function sendMessage() {
24
+ var text = ctx.inputEl.value.trim();
25
+ var images = pendingImages.slice();
26
+ if (!text && images.length === 0 && pendingPastes.length === 0) return;
27
+ hideSlashMenu();
28
+ if (ctx.hideSuggestionChips) ctx.hideSuggestionChips();
29
+
30
+ if (text === "/clear") {
31
+ ctx.inputEl.value = "";
32
+ clearPendingImages();
33
+ autoResize();
34
+ if (ctx.ws && ctx.connected) {
35
+ ctx.ws.send(JSON.stringify({ type: "new_session" }));
36
+ }
37
+ return;
38
+ }
39
+
40
+ if (text === "/rewind") {
41
+ ctx.inputEl.value = "";
42
+ clearPendingImages();
43
+ autoResize();
44
+ if (ctx.messageUuidMap().length === 0) {
45
+ ctx.addSystemMessage("No rewind points available in this session.", true);
46
+ } else {
47
+ setRewindMode(!isRewindMode());
48
+ }
49
+ return;
50
+ }
51
+
52
+ if (text === "/context") {
53
+ ctx.inputEl.value = "";
54
+ clearPendingImages();
55
+ autoResize();
56
+ if (ctx.toggleContextPanel) ctx.toggleContextPanel();
57
+ return;
58
+ }
59
+
60
+ if (text === "/usage") {
61
+ ctx.inputEl.value = "";
62
+ clearPendingImages();
63
+ autoResize();
64
+ if (ctx.toggleUsagePanel) ctx.toggleUsagePanel();
65
+ return;
66
+ }
67
+
68
+ if (text === "/status") {
69
+ ctx.inputEl.value = "";
70
+ clearPendingImages();
71
+ autoResize();
72
+ if (ctx.toggleStatusPanel) ctx.toggleStatusPanel();
73
+ return;
74
+ }
75
+
76
+ if (!ctx.connected) {
77
+ ctx.addSystemMessage("Not connected — message not sent.", true);
78
+ return;
79
+ }
80
+
81
+ var pastes = pendingPastes.map(function (p) { return p.text; });
82
+ ctx.addUserMessage(text, images.length > 0 ? images : null, pastes.length > 0 ? pastes : null);
83
+
84
+ var payload = { type: "message", text: text || "" };
85
+ if (images.length > 0) {
86
+ payload.images = images;
87
+ }
88
+ if (pastes.length > 0) {
89
+ payload.pastes = pastes;
90
+ }
91
+ ctx.ws.send(JSON.stringify(payload));
92
+
93
+ ctx.inputEl.value = "";
94
+ sendInputSync();
95
+ clearPendingImages();
96
+ autoResize();
97
+ ctx.inputEl.focus();
98
+ }
99
+
100
+ export function autoResize() {
101
+ ctx.inputEl.style.height = "auto";
102
+ ctx.inputEl.style.height = Math.min(ctx.inputEl.scrollHeight, 120) + "px";
103
+ }
104
+
105
+ // --- File path extraction from clipboard ---
106
+ function extractFilePaths(cd) {
107
+ var paths = [];
108
+
109
+ // 1. Check text/uri-list for file:// URIs (Finder on some browsers)
110
+ var uriList = cd.getData("text/uri-list");
111
+ if (uriList) {
112
+ var lines = uriList.split(/\r?\n/);
113
+ for (var i = 0; i < lines.length; i++) {
114
+ var line = lines[i].trim();
115
+ if (line && !line.startsWith("#") && line.startsWith("file://")) {
116
+ paths.push(decodeURIComponent(line.replace("file://", "")));
117
+ }
118
+ }
119
+ if (paths.length > 0) return paths;
120
+ }
121
+
122
+ // 2. Check if text/plain looks like file path(s) while files are present
123
+ // (Finder Cmd+C puts filename in text/plain, Cmd+Option+C puts full path)
124
+ if (cd.files && cd.files.length > 0) {
125
+ var plainText = cd.getData("text/plain");
126
+ if (plainText) {
127
+ var textLines = plainText.split(/\r?\n/).filter(function (l) { return l.trim(); });
128
+ for (var i = 0; i < textLines.length; i++) {
129
+ var p = textLines[i].trim();
130
+ if (p.startsWith("/") || p.startsWith("~")) {
131
+ paths.push(p);
132
+ }
133
+ }
134
+ if (paths.length > 0) return paths;
135
+ }
136
+ // 3. Fallback: files present but no path in text, use filenames
137
+ for (var i = 0; i < cd.files.length; i++) {
138
+ var f = cd.files[i];
139
+ if (f.name && f.type.indexOf("image/") !== 0) {
140
+ paths.push(f.name);
141
+ }
142
+ }
143
+ }
144
+
145
+ return paths;
146
+ }
147
+
148
+ // --- Insert text at cursor in textarea ---
149
+ function insertTextAtCursor(text) {
150
+ var el = ctx.inputEl;
151
+ el.focus();
152
+ var start = el.selectionStart;
153
+ var end = el.selectionEnd;
154
+ var before = el.value.substring(0, start);
155
+ var after = el.value.substring(end);
156
+ // Add space before if cursor is right after non-space text
157
+ if (before.length > 0 && before[before.length - 1] !== " " && before[before.length - 1] !== "\n") {
158
+ text = " " + text;
159
+ }
160
+ el.value = before + text + after;
161
+ el.selectionStart = el.selectionEnd = start + text.length;
162
+ autoResize();
163
+ sendInputSync();
164
+ }
165
+
166
+ // --- Image paste ---
167
+ function addPendingImage(dataUrl) {
168
+ var commaIdx = dataUrl.indexOf(",");
169
+ if (commaIdx === -1) return;
170
+ var header = dataUrl.substring(0, commaIdx);
171
+ var data = dataUrl.substring(commaIdx + 1);
172
+ var typeMatch = header.match(/data:(image\/[^;,]+)/);
173
+ if (!typeMatch || !data) return;
174
+ pendingImages.push({ mediaType: typeMatch[1], data: data });
175
+ renderInputPreviews();
176
+ }
177
+
178
+ function removePendingImage(idx) {
179
+ pendingImages.splice(idx, 1);
180
+ renderInputPreviews();
181
+ }
182
+
183
+ export function clearPendingImages() {
184
+ pendingImages = [];
185
+ pendingPastes = [];
186
+ renderInputPreviews();
187
+ }
188
+
189
+ function removePendingPaste(idx) {
190
+ pendingPastes.splice(idx, 1);
191
+ renderInputPreviews();
192
+ }
193
+
194
+ function renderInputPreviews() {
195
+ var bar = ctx.imagePreviewBar;
196
+ bar.innerHTML = "";
197
+ if (pendingImages.length === 0 && pendingPastes.length === 0) {
198
+ bar.classList.remove("visible");
199
+ return;
200
+ }
201
+ bar.classList.add("visible");
202
+
203
+ // Image thumbnails
204
+ for (var i = 0; i < pendingImages.length; i++) {
205
+ (function (idx) {
206
+ var wrap = document.createElement("div");
207
+ wrap.className = "image-preview-thumb";
208
+ var img = document.createElement("img");
209
+ img.src = "data:" + pendingImages[idx].mediaType + ";base64," + pendingImages[idx].data;
210
+ img.addEventListener("click", function () {
211
+ if (ctx.showImageModal) ctx.showImageModal(this.src);
212
+ });
213
+ var removeBtn = document.createElement("button");
214
+ removeBtn.className = "image-preview-remove";
215
+ removeBtn.innerHTML = iconHtml("x");
216
+ removeBtn.addEventListener("click", function () {
217
+ removePendingImage(idx);
218
+ });
219
+ wrap.appendChild(img);
220
+ wrap.appendChild(removeBtn);
221
+ bar.appendChild(wrap);
222
+ })(i);
223
+ }
224
+
225
+ // Pasted content chips
226
+ for (var j = 0; j < pendingPastes.length; j++) {
227
+ (function (idx) {
228
+ var chip = document.createElement("div");
229
+ chip.className = "pasted-chip";
230
+ var preview = document.createElement("span");
231
+ preview.className = "pasted-chip-preview";
232
+ preview.textContent = pendingPastes[idx].preview;
233
+ var label = document.createElement("span");
234
+ label.className = "pasted-chip-label";
235
+ label.textContent = "PASTED";
236
+ var removeBtn = document.createElement("button");
237
+ removeBtn.className = "pasted-chip-remove";
238
+ removeBtn.innerHTML = iconHtml("x");
239
+ removeBtn.addEventListener("click", function (e) {
240
+ e.stopPropagation();
241
+ removePendingPaste(idx);
242
+ });
243
+ chip.appendChild(preview);
244
+ chip.appendChild(label);
245
+ chip.appendChild(removeBtn);
246
+ bar.appendChild(chip);
247
+ })(j);
248
+ }
249
+
250
+ refreshIcons();
251
+ }
252
+
253
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB
254
+ var RESIZE_MAX_DIM = 1920;
255
+ var RESIZE_QUALITY = 0.85;
256
+
257
+ function readImageBlob(blob) {
258
+ var reader = new FileReader();
259
+ reader.onload = function (ev) {
260
+ var dataUrl = ev.target.result;
261
+ // Check base64 payload size (~3/4 of base64 length)
262
+ var commaIdx = dataUrl.indexOf(",");
263
+ var b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : "";
264
+ var estimatedBytes = b64.length * 0.75;
265
+
266
+ if (estimatedBytes <= MAX_IMAGE_BYTES) {
267
+ addPendingImage(dataUrl);
268
+ return;
269
+ }
270
+
271
+ // Resize via canvas
272
+ var img = new Image();
273
+ img.onload = function () {
274
+ var w = img.naturalWidth;
275
+ var h = img.naturalHeight;
276
+ var scale = Math.min(RESIZE_MAX_DIM / Math.max(w, h), 1);
277
+ var nw = Math.round(w * scale);
278
+ var nh = Math.round(h * scale);
279
+ var canvas = document.createElement("canvas");
280
+ canvas.width = nw;
281
+ canvas.height = nh;
282
+ var cx = canvas.getContext("2d");
283
+ cx.drawImage(img, 0, 0, nw, nh);
284
+ var resized = canvas.toDataURL("image/jpeg", RESIZE_QUALITY);
285
+ addPendingImage(resized);
286
+ };
287
+ img.src = dataUrl;
288
+ };
289
+ reader.readAsDataURL(blob);
290
+ }
291
+
292
+ // --- Slash menu ---
293
+ function getAllCommands() {
294
+ return builtinCommands.concat(ctx.slashCommands());
295
+ }
296
+
297
+ function showSlashMenu(filter) {
298
+ var query = filter.toLowerCase();
299
+ slashFiltered = getAllCommands().filter(function (c) {
300
+ return c.name.toLowerCase().indexOf(query) !== -1;
301
+ });
302
+ if (slashFiltered.length === 0) { hideSlashMenu(); return; }
303
+
304
+ slashActiveIdx = 0;
305
+ ctx.slashMenu.innerHTML = slashFiltered.map(function (c, i) {
306
+ return '<div class="slash-item' + (i === 0 ? ' active' : '') + '" data-idx="' + i + '">' +
307
+ '<span class="slash-cmd">/' + c.name + '</span>' +
308
+ '<span class="slash-desc">' + c.desc + '</span>' +
309
+ '</div>';
310
+ }).join("");
311
+ ctx.slashMenu.classList.add("visible");
312
+
313
+ ctx.slashMenu.querySelectorAll(".slash-item").forEach(function (el) {
314
+ el.addEventListener("click", function () {
315
+ selectSlashItem(parseInt(el.dataset.idx));
316
+ });
317
+ });
318
+ }
319
+
320
+ export function hideSlashMenu() {
321
+ ctx.slashMenu.classList.remove("visible");
322
+ ctx.slashMenu.innerHTML = "";
323
+ slashActiveIdx = -1;
324
+ slashFiltered = [];
325
+ }
326
+
327
+ function selectSlashItem(idx) {
328
+ if (idx < 0 || idx >= slashFiltered.length) return;
329
+ var cmd = slashFiltered[idx];
330
+ ctx.inputEl.value = "/" + cmd.name + " ";
331
+ hideSlashMenu();
332
+ autoResize();
333
+ ctx.inputEl.focus();
334
+ }
335
+
336
+ function updateSlashHighlight() {
337
+ ctx.slashMenu.querySelectorAll(".slash-item").forEach(function (el, i) {
338
+ el.classList.toggle("active", i === slashActiveIdx);
339
+ });
340
+ var activeEl = ctx.slashMenu.querySelector(".slash-item.active");
341
+ if (activeEl) activeEl.scrollIntoView({ block: "nearest" });
342
+ }
343
+
344
+ // --- Input sync across devices ---
345
+ function sendInputSync() {
346
+ if (isRemoteInput) return;
347
+ if (ctx.ws && ctx.connected) {
348
+ ctx.ws.send(JSON.stringify({ type: "input_sync", text: ctx.inputEl.value }));
349
+ }
350
+ }
351
+
352
+ export function handleInputSync(text) {
353
+ isRemoteInput = true;
354
+ ctx.inputEl.value = text;
355
+ autoResize();
356
+ isRemoteInput = false;
357
+ }
358
+
359
+ // --- Attach menu ---
360
+ var attachMenuOpen = false;
361
+
362
+ function toggleAttachMenu() {
363
+ var menu = document.getElementById("attach-menu");
364
+ if (!menu) return;
365
+ attachMenuOpen = !attachMenuOpen;
366
+ menu.classList.toggle("hidden", !attachMenuOpen);
367
+ }
368
+
369
+ function closeAttachMenu() {
370
+ var menu = document.getElementById("attach-menu");
371
+ if (menu) menu.classList.add("hidden");
372
+ attachMenuOpen = false;
373
+ }
374
+
375
+ function createFileInput(accept, capture, multiple) {
376
+ var input = document.createElement("input");
377
+ input.type = "file";
378
+ input.accept = accept;
379
+ if (capture) input.setAttribute("capture", capture);
380
+ if (multiple) input.multiple = true;
381
+ input.style.display = "none";
382
+ document.body.appendChild(input);
383
+
384
+ input.addEventListener("change", function () {
385
+ if (input.files) {
386
+ for (var i = 0; i < input.files.length; i++) {
387
+ if (input.files[i].type.indexOf("image/") === 0) {
388
+ readImageBlob(input.files[i]);
389
+ }
390
+ }
391
+ }
392
+ document.body.removeChild(input);
393
+ });
394
+
395
+ input.click();
396
+ }
397
+
398
+ // --- Init ---
399
+ export function initInput(_ctx) {
400
+ ctx = _ctx;
401
+
402
+ // Attach button
403
+ var isTouchDevice = "ontouchstart" in window;
404
+ var attachBtn = document.getElementById("attach-btn");
405
+ if (attachBtn) {
406
+ attachBtn.addEventListener("click", function (e) {
407
+ e.stopPropagation();
408
+ // Desktop: skip menu, open file picker directly
409
+ if (!isTouchDevice) {
410
+ createFileInput("image/*", null, true);
411
+ return;
412
+ }
413
+ toggleAttachMenu();
414
+ });
415
+ }
416
+
417
+ var cameraBtn = document.getElementById("attach-camera");
418
+ if (cameraBtn) {
419
+ cameraBtn.addEventListener("click", function () {
420
+ closeAttachMenu();
421
+ createFileInput("image/*", "environment");
422
+ });
423
+ }
424
+
425
+ var photosBtn = document.getElementById("attach-photos");
426
+ if (photosBtn) {
427
+ photosBtn.addEventListener("click", function () {
428
+ closeAttachMenu();
429
+ createFileInput("image/*", null, true);
430
+ });
431
+ }
432
+
433
+ // Close attach menu when clicking outside
434
+ document.addEventListener("click", function (e) {
435
+ if (attachMenuOpen) {
436
+ var wrap = document.getElementById("attach-wrap");
437
+ if (wrap && !wrap.contains(e.target)) {
438
+ closeAttachMenu();
439
+ }
440
+ }
441
+ });
442
+
443
+ // Paste handler
444
+ document.addEventListener("paste", function (e) {
445
+ var cd = e.clipboardData;
446
+ if (!cd) return;
447
+
448
+ var found = false;
449
+
450
+ // Try clipboardData.files first (better Safari/iOS support)
451
+ if (cd.files && cd.files.length > 0) {
452
+ for (var i = 0; i < cd.files.length; i++) {
453
+ if (cd.files[i].type.indexOf("image/") === 0) {
454
+ found = true;
455
+ readImageBlob(cd.files[i]);
456
+ }
457
+ }
458
+ }
459
+
460
+ // Fall back to clipboardData.items
461
+ if (!found && cd.items) {
462
+ for (var i = 0; i < cd.items.length; i++) {
463
+ if (cd.items[i].type.indexOf("image/") === 0) {
464
+ var blob = cd.items[i].getAsFile();
465
+ if (blob) {
466
+ found = true;
467
+ readImageBlob(blob);
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ // File path paste: detect file:// URIs or Finder file references
474
+ if (!found) {
475
+ var filePaths = extractFilePaths(cd);
476
+ if (filePaths.length > 0) {
477
+ e.preventDefault();
478
+ insertTextAtCursor(filePaths.join("\n"));
479
+ found = true;
480
+ }
481
+ }
482
+
483
+ // Long text paste → pasted chip
484
+ if (!found) {
485
+ var pastedText = cd.getData("text/plain");
486
+ if (pastedText && pastedText.length >= 500) {
487
+ e.preventDefault();
488
+ var preview = pastedText.substring(0, 50).replace(/\n/g, " ");
489
+ if (pastedText.length > 50) preview += "...";
490
+ pendingPastes.push({ text: pastedText, preview: preview });
491
+ renderInputPreviews();
492
+ found = true;
493
+ }
494
+ }
495
+
496
+ if (found) e.preventDefault();
497
+ });
498
+
499
+ // Input event handlers
500
+ ctx.inputEl.addEventListener("input", function () {
501
+ autoResize();
502
+ sendInputSync();
503
+ if (ctx.hideSuggestionChips) ctx.hideSuggestionChips();
504
+ var val = ctx.inputEl.value;
505
+ if (val.startsWith("/") && !val.includes(" ") && val.length > 1) {
506
+ showSlashMenu(val.substring(1));
507
+ } else if (val === "/") {
508
+ showSlashMenu("");
509
+ } else {
510
+ hideSlashMenu();
511
+ }
512
+ });
513
+
514
+ ctx.inputEl.addEventListener("compositionstart", function () { isComposing = true; });
515
+ ctx.inputEl.addEventListener("compositionend", function () { isComposing = false; });
516
+
517
+ ctx.inputEl.addEventListener("keydown", function (e) {
518
+ if (slashFiltered.length > 0 && ctx.slashMenu.classList.contains("visible")) {
519
+ if (e.key === "ArrowDown") {
520
+ e.preventDefault();
521
+ slashActiveIdx = (slashActiveIdx + 1) % slashFiltered.length;
522
+ updateSlashHighlight();
523
+ return;
524
+ }
525
+ if (e.key === "ArrowUp") {
526
+ e.preventDefault();
527
+ slashActiveIdx = (slashActiveIdx - 1 + slashFiltered.length) % slashFiltered.length;
528
+ updateSlashHighlight();
529
+ return;
530
+ }
531
+ if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
532
+ e.preventDefault();
533
+ selectSlashItem(slashActiveIdx);
534
+ return;
535
+ }
536
+ if (e.key === "Escape") {
537
+ e.preventDefault();
538
+ hideSlashMenu();
539
+ return;
540
+ }
541
+ }
542
+
543
+ // Ctrl+J: insert newline (like Claude CLI)
544
+ if (e.key === "j" && e.ctrlKey && !e.metaKey) {
545
+ e.preventDefault();
546
+ var ta = ctx.inputEl;
547
+ var start = ta.selectionStart;
548
+ var end = ta.selectionEnd;
549
+ var val = ta.value;
550
+ ta.value = val.substring(0, start) + "\n" + val.substring(end);
551
+ ta.selectionStart = ta.selectionEnd = start + 1;
552
+ autoResize();
553
+ return;
554
+ }
555
+
556
+ if (e.key === "Enter" && !e.shiftKey && !isComposing) {
557
+ // Mobile: Enter inserts newline, send via button only
558
+ if ("ontouchstart" in window) {
559
+ return;
560
+ }
561
+ e.preventDefault();
562
+ // If suggestion chips are visible, accept chip instead of sending
563
+ if (ctx.acceptSuggestionChip && ctx.acceptSuggestionChip()) {
564
+ return;
565
+ }
566
+ sendMessage();
567
+ }
568
+ });
569
+
570
+ // Mobile: switch enterkeyhint to "enter" so keyboard shows return key
571
+ if ("ontouchstart" in window) {
572
+ ctx.inputEl.setAttribute("enterkeyhint", "enter");
573
+ }
574
+
575
+ // Send/Stop button
576
+ ctx.sendBtn.addEventListener("click", function () {
577
+ if (ctx.processing && ctx.connected) {
578
+ ctx.ws.send(JSON.stringify({ type: "stop" }));
579
+ return;
580
+ }
581
+ sendMessage();
582
+ });
583
+ ctx.sendBtn.addEventListener("dblclick", function (e) { e.preventDefault(); });
584
+ }