claude-simple 0.2.24 → 0.2.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
package/public/api.js CHANGED
@@ -54,6 +54,7 @@ export async function api(method, path, body, signal, opts = {}) {
54
54
  method,
55
55
  signal,
56
56
  headers,
57
+ ...(opts.cache ? { cache: opts.cache } : {}),
57
58
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
58
59
  });
59
60
  if (res.status === 304) return NOT_MODIFIED;
package/public/chat.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { watch, delegate, esc, $ } from './lib.js';
3
3
  import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
4
4
  currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
5
- getDefaultEffort, addCustomModel, setCurrentModel, chatDensity, wsStatus } from './state.js';
5
+ getDefaultEffort, addCustomModel, setCurrentModel, chatDensity, wsStatus, sessionMetaMap, metaKey } from './state.js';
6
6
  import { sendWs, api, authHeaders, flushWsQueue, clearWsQueue } from './api.js';
7
7
  import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
8
8
  import { pushNotification } from './notifications.js';
@@ -37,13 +37,39 @@ let sawDisconnect = false;
37
37
  let lastWsMachine = null;
38
38
  let reconnectQuiet = false;
39
39
  const HUB_CURSOR_KEY = 'hubStreamCursors';
40
+ const assistantMessageRefs = new Map();
41
+ let assistantMessageSeq = 0;
42
+ const renderedGrokQueuePrompts = new Set();
43
+ const PENDING_QUEUE_KEY = 'pendingUserTexts';
40
44
  /** FIFO messages deliberately queued while a turn is running. */
41
- let pendingUserTexts = [];
45
+ let pendingUserTexts = (() => {
46
+ try {
47
+ const saved = JSON.parse(sessionStorage.getItem(PENDING_QUEUE_KEY) || '[]');
48
+ return Array.isArray(saved) ? saved.filter(x => x?.key && x?.text).slice(-30) : [];
49
+ } catch { return []; }
50
+ })();
51
+ const announcedRestoredQueues = new Set();
42
52
  // Composer state belongs to a conversation, never to the shared textarea.
43
53
  // Keeping this in memory deliberately avoids persisting potentially sensitive
44
54
  // prompts to disk while still making session switches lossless.
45
55
  const composerDrafts = new Map();
46
56
 
57
+ function persistPendingUserTexts() {
58
+ try {
59
+ if (pendingUserTexts.length) sessionStorage.setItem(PENDING_QUEUE_KEY, JSON.stringify(pendingUserTexts.slice(-30)));
60
+ else sessionStorage.removeItem(PENDING_QUEUE_KEY);
61
+ } catch { /* private mode / quota: in-memory queue still works */ }
62
+ }
63
+
64
+ function announceRestoredQueue() {
65
+ const key = draftKey();
66
+ const count = pendingUserTexts.filter(item => item.key === key).length;
67
+ if (count && !announcedRestoredQueues.has(key)) {
68
+ announcedRestoredQueues.add(key);
69
+ appendSystemMsg(`⌛ Restored ${count} queued prompt${count === 1 ? '' : 's'} in this browser tab`);
70
+ }
71
+ }
72
+
47
73
  function hubCursorKey(sessionId = ctx.sessionId, agent = ctx.agent, machineId = ctx.machineId) {
48
74
  return sessionId ? `${machineId || ''}:${agent || 'claude'}:${sessionId}` : '';
49
75
  }
@@ -106,6 +132,7 @@ export function parkQueuedMessages(identity) {
106
132
  const queued = pendingUserTexts.filter(item => item.key === key);
107
133
  if (!queued.length) return;
108
134
  pendingUserTexts = pendingUserTexts.filter(item => item.key !== key);
135
+ persistPendingUserTexts();
109
136
  const existing = composerDrafts.get(key) || '';
110
137
  composerDrafts.set(key, [existing, ...queued.map(item => item.text)].filter(Boolean).join('\n\n'));
111
138
  }
@@ -126,7 +153,7 @@ function bindSocketLifecycle() {
126
153
  ensureChatSocket({ probe: true });
127
154
  // Re-subscribe so server can re-attach stream / clear stuck busy state
128
155
  if (ctx.ws?.readyState === WebSocket.OPEN && ctx.sessionId) {
129
- try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
156
+ try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId, agent: ctx.agent })); } catch { /* ignore */ }
130
157
  }
131
158
  }
132
159
  });
@@ -225,6 +252,22 @@ function syncWakeLock() {
225
252
  setWakeLockDesired(need);
226
253
  }
227
254
 
255
+ /** External CLI transcripts are observable, but this UI must not race their
256
+ * terminal owner by submitting another turn into the same native session. */
257
+ export function setExternalSessionReadOnly(readOnly) {
258
+ ctx.externalSessionReadOnly = !!readOnly;
259
+ const input = $('chat-input');
260
+ const send = $('send-btn');
261
+ const attach = $('btn-attach');
262
+ if (input) {
263
+ input.disabled = !!readOnly;
264
+ if (readOnly) input.placeholder = '外部终端会话 · 实时同步中(只读)';
265
+ else if (!isProcessing.peek()) updateInputMode(input.value || '');
266
+ }
267
+ if (send) send.disabled = !!readOnly;
268
+ if (attach) attach.disabled = !!readOnly;
269
+ }
270
+
228
271
  /** Manual reconnect (toolbar / banner). Resets backoff and forces a new socket. */
229
272
  export function refreshConnection() {
230
273
  reconnectDelay = 400;
@@ -273,9 +316,10 @@ export function connectWS(opts = {}) {
273
316
  wsStatus.value = 'open';
274
317
  updateConnDot();
275
318
  if (ctx.sessionId) {
276
- try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
319
+ try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId, agent: ctx.agent })); } catch { /* ignore */ }
277
320
  }
278
321
  flushWsQueue();
322
+ announceRestoredQueue();
279
323
  startHeartbeat(sock, gen);
280
324
  syncWakeLock();
281
325
  if (sawDisconnect && !reconnectQuiet) {
@@ -307,6 +351,10 @@ export function connectWS(opts = {}) {
307
351
  // Server says idle — unlock (fixes Grok lingering process / missed complete)
308
352
  isProcessing.value = false;
309
353
  flushPendingUserText();
354
+ } else {
355
+ // After reload the UI starts idle; server status is still authoritative
356
+ // enough to release browser-restored queued prompts.
357
+ flushPendingUserText();
310
358
  }
311
359
  syncWakeLock();
312
360
  return;
@@ -336,6 +384,7 @@ function flushPendingUserText() {
336
384
  // opened (or are converted to a draft during navigation).
337
385
  if (index < 0) return;
338
386
  const [pending] = pendingUserTexts.splice(index, 1);
387
+ persistPendingUserTexts();
339
388
  const input = $('chat-input');
340
389
  if (input) {
341
390
  input.value = pending.text;
@@ -379,10 +428,27 @@ function handleWsMessage(msg) {
379
428
  document.dispatchEvent(new CustomEvent('sessions-changed'));
380
429
  return;
381
430
  }
431
+ if (msg.type === 'external-sessions-changed') {
432
+ document.dispatchEvent(new CustomEvent('sessions-changed'));
433
+ return;
434
+ }
435
+ if (msg.type === 'external-session-updated') {
436
+ // The external CLI owns this conversation. Reloading the normalized native
437
+ // transcript is safer than trying to synthesize its varied JSONL events in
438
+ // the browser, and keeps reconnects/idempotency straightforward.
439
+ setExternalSessionReadOnly(true);
440
+ document.dispatchEvent(new CustomEvent('sessions-changed'));
441
+ document.dispatchEvent(new CustomEvent('external-session-updated', { detail: msg }));
442
+ return;
443
+ }
382
444
  if (msg.type === 'notification' && msg.notification) {
383
445
  pushNotification(msg.notification);
384
446
  return;
385
447
  }
448
+ if (msg.type === 'grok-queue-status') {
449
+ renderGrokQueuePrompts(msg);
450
+ return;
451
+ }
386
452
  if (msg.type === 'result') {
387
453
  finalizeThoughtStream();
388
454
  finalizeAssistantStream();
@@ -555,6 +621,7 @@ function finalizeAssistantStream() {
555
621
  liveAsstBody.classList.add('md');
556
622
  liveAsstBody.innerHTML = renderMarkdown(text);
557
623
  enhanceMarkdown(liveAsstEl);
624
+ addAssistantMessageActions(liveAsstEl, text);
558
625
  } else {
559
626
  liveAsstEl.remove();
560
627
  }
@@ -811,6 +878,10 @@ export function sendMessage() {
811
878
  const input = $('chat-input');
812
879
  const text = input?.value.trim();
813
880
  if (!text || !currentProject.peek()) return;
881
+ if (ctx.externalSessionReadOnly) {
882
+ appendSystemMsg('此会话由外部终端运行,agentui 仅实时同步,不会发送输入。');
883
+ return;
884
+ }
814
885
 
815
886
  // While a turn is running, queue the next message (common Grok complaint).
816
887
  if (isProcessing.peek()) {
@@ -829,14 +900,16 @@ export function sendMessage() {
829
900
  ...(currentPermission.peek() === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
830
901
  },
831
902
  });
903
+ appendQueuedGrokPrompt(text);
832
904
  input.value = '';
833
905
  autoResize(input);
834
906
  updateInputMode('');
835
907
  clearPendingAttachments();
836
- appendSystemMsg(ok ? '已加入 Grok 服务端队列,刷新页面不会丢失' : '连接未就绪,消息将在重连后发送');
908
+ appendSystemMsg(ok ? '已加入 Grok 服务端队列,刷新页面仍会显示' : '连接未就绪,消息将在重连后发送');
837
909
  return;
838
910
  }
839
911
  pendingUserTexts.push({ key: draftKey(), text });
912
+ persistPendingUserTexts();
840
913
  input.value = '';
841
914
  autoResize(input);
842
915
  updateInputMode('');
@@ -907,6 +980,8 @@ export function stopProcessing() {
907
980
  sendWs({ type: 'command', cmd: 'turn.interrupt', sessionId: ctx.sessionId });
908
981
  }
909
982
  pendingUserTexts = pendingUserTexts.filter(item => item.key !== draftKey());
983
+ persistPendingUserTexts();
984
+ markQueuedGrokPromptsCancelled();
910
985
  markTurnDone();
911
986
  }
912
987
 
@@ -1017,7 +1092,7 @@ function bubbleHtml(role, text, ts) {
1017
1092
  case 'assistant': {
1018
1093
  const long = text.length > ASST_COLLAPSE;
1019
1094
  const inner = long ? `<div class="msg-body">${renderMarkdown(text)}</div>${expandBtn}` : renderMarkdown(text);
1020
- bubble = `<div class="max-w-[min(92%,52rem)] rounded-2xl rounded-tl-sm px-3.5 py-2.5 text-sm bg-base-200 border border-base-300/40 break-words md leading-relaxed${long ? ' msg-card' : ''}">${inner}</div>`;
1095
+ bubble = `<div class="msg-assistant-content max-w-[min(92%,52rem)] rounded-2xl rounded-tl-sm px-3.5 py-2.5 text-sm bg-base-200 border border-base-300/40 break-words md leading-relaxed${long ? ' msg-card' : ''}">${inner}</div>`;
1021
1096
  break;
1022
1097
  }
1023
1098
  case 'error':
@@ -1047,6 +1122,7 @@ export function appendMsg(role, _label, text, opts = {}) {
1047
1122
  const ts = coerceTs(opts.ts) ?? (role === 'system' ? null : Date.now());
1048
1123
  msgs.insertAdjacentHTML('beforeend', bubbleHtml(role, text, ts));
1049
1124
  enhanceMarkdown(msgs.lastElementChild);
1125
+ if (role === 'assistant') addAssistantMessageActions(msgs.lastElementChild, text, opts);
1050
1126
  stickBottom(msgs, role === 'user');
1051
1127
  }
1052
1128
 
@@ -1345,6 +1421,122 @@ export function clearMessages() {
1345
1421
  toolBatch = null;
1346
1422
  liveAsstEl = liveAsstBody = null;
1347
1423
  liveThoughtEl = liveThoughtBody = null;
1424
+ assistantMessageRefs.clear();
1425
+ renderedGrokQueuePrompts.clear();
1426
+ }
1427
+
1428
+ function queuedGrokPromptKey(text, sessionId = ctx.sessionId) {
1429
+ return `${sessionId || ''}\n${String(text || '')}`;
1430
+ }
1431
+
1432
+ /** Paint a server-accepted Grok follow-up before the native transcript writes
1433
+ * it. This makes the queued user prompt visible both immediately and after a
1434
+ * reconnect (the server sends the same snapshot on subscribe). */
1435
+ function appendQueuedGrokPrompt(text, sessionId = ctx.sessionId) {
1436
+ const key = queuedGrokPromptKey(text, sessionId);
1437
+ if (!text || renderedGrokQueuePrompts.has(key)) return;
1438
+ renderedGrokQueuePrompts.add(key);
1439
+ appendMsg('user', 'You', text, { ts: Date.now() });
1440
+ $('messages')?.lastElementChild?.classList.add('queued-user-msg');
1441
+ }
1442
+
1443
+ function renderGrokQueuePrompts(msg) {
1444
+ if ((msg.sessionId || ctx.sessionId) !== ctx.sessionId) return;
1445
+ const items = Array.isArray(msg.items) ? msg.items.filter(x => typeof x === 'string' && x.trim()) : [];
1446
+ for (const text of items) appendQueuedGrokPrompt(text, msg.sessionId || ctx.sessionId);
1447
+ if (items.length) appendSystemMsg(`⌛ Grok queue · ${items.length} prompt${items.length === 1 ? '' : 's'} waiting`);
1448
+ }
1449
+
1450
+ function markQueuedGrokPromptsCancelled() {
1451
+ const msgs = $('messages');
1452
+ if (!msgs) return;
1453
+ msgs.querySelectorAll('.queued-user-msg').forEach(row => {
1454
+ row.classList.remove('queued-user-msg');
1455
+ row.classList.add('cancelled-queued-user-msg');
1456
+ });
1457
+ renderedGrokQueuePrompts.clear();
1458
+ }
1459
+
1460
+ /** Per-answer controls stay below the assistant message. Raw answer text lives
1461
+ * only in this in-memory map, never in a DOM data attribute. */
1462
+ function addAssistantMessageActions(row, text, opts = {}) {
1463
+ if (!row || !text || row.querySelector('.msg-actions')) return;
1464
+ const key = `a${++assistantMessageSeq}`;
1465
+ assistantMessageRefs.set(key, {
1466
+ text,
1467
+ historyIndex: Number.isInteger(opts.historyIndex) ? opts.historyIndex : null,
1468
+ sessionId: ctx.sessionId,
1469
+ agent: ctx.agent || currentAgent.peek() || 'claude',
1470
+ machineId: ctx.machineId || null,
1471
+ });
1472
+ const canFork = !['gemini', 'cursor'].includes(ctx.agent || currentAgent.peek() || 'claude');
1473
+ const actions = document.createElement('div');
1474
+ actions.className = 'msg-actions';
1475
+ actions.innerHTML = `
1476
+ <button type="button" class="msg-action-btn" data-copy-message="${esc(key)}" title="Copy answer">Copy</button>
1477
+ ${canFork ? `<button type="button" class="msg-action-btn" data-fork-message="${esc(key)}" title="Start a branch from this answer">Fork</button>` : ''}`;
1478
+ const timestamp = row.querySelector('.msg-ts');
1479
+ row.insertBefore(actions, timestamp || null);
1480
+ }
1481
+
1482
+ async function copyAssistantMessage(button) {
1483
+ const ref = assistantMessageRefs.get(button.dataset.copyMessage || '');
1484
+ if (!ref) return;
1485
+ try {
1486
+ await navigator.clipboard.writeText(ref.text);
1487
+ button.textContent = 'Copied';
1488
+ } catch {
1489
+ button.textContent = 'Failed';
1490
+ }
1491
+ setTimeout(() => { button.textContent = 'Copy'; }, 1300);
1492
+ }
1493
+
1494
+ async function forkAssistantMessage(button) {
1495
+ const ref = assistantMessageRefs.get(button.dataset.forkMessage || '');
1496
+ if (!ref || !ref.sessionId || ref.sessionId !== ctx.sessionId) return;
1497
+ button.disabled = true;
1498
+ button.textContent = 'Forking…';
1499
+ try {
1500
+ const res = await api('POST', `/api/sessions/${encodeURIComponent(ref.sessionId)}/fork`, {
1501
+ sourceAgent: ref.agent,
1502
+ ...(ref.historyIndex != null ? { upTo: ref.historyIndex } : {}),
1503
+ assistantText: ref.text,
1504
+ }, undefined, { machineId: ref.machineId || undefined });
1505
+ if (!res?.success || !res.targetSessionId) throw new Error('Fork did not create a session');
1506
+ const branch = {
1507
+ parentSessionId: ref.sessionId,
1508
+ parentAgent: ref.agent,
1509
+ parentMachineId: ref.machineId,
1510
+ forkMessageIndex: Number.isInteger(res.forkMessageIndex) ? res.forkMessageIndex : ref.historyIndex,
1511
+ forkedAt: Date.now(),
1512
+ };
1513
+ const metaRes = await api('PUT', '/api/sessions/meta', {
1514
+ sessionId: res.targetSessionId, agent: res.targetAgent || ref.agent, branch,
1515
+ machineId: ref.machineId || undefined,
1516
+ }, undefined, { machineId: ref.machineId || undefined });
1517
+ const key = metaKey(res.targetAgent || ref.agent, res.targetSessionId, ref.machineId);
1518
+ sessionMetaMap.value = {
1519
+ ...sessionMetaMap.peek(),
1520
+ [key]: {
1521
+ favorite: Boolean(metaRes?.favorite), notes: metaRes?.notes || '', title: metaRes?.title || '',
1522
+ hidden: Boolean(metaRes?.hidden), branch: metaRes?.branch || branch, updatedAt: metaRes?.updatedAt || Date.now(),
1523
+ },
1524
+ };
1525
+ const [{ loadAllSessions }, { resumeSession }] = await Promise.all([
1526
+ import('./shell/session-list.js'), import('./shell/session-nav.js'),
1527
+ ]);
1528
+ await loadAllSessions({ waitFresh: true });
1529
+ await resumeSession(res.targetSessionId, currentProject.peek()?.path || null, ctx.configDir || null,
1530
+ res.targetAgent || ref.agent, ref.machineId || null);
1531
+ appendSystemMsg('Fork created — this branch can now continue independently.');
1532
+ } catch (err) {
1533
+ button.textContent = 'Fork failed';
1534
+ appendSystemMsg(`Fork failed: ${err.message}`);
1535
+ setTimeout(() => { button.textContent = 'Fork'; }, 1800);
1536
+ } finally {
1537
+ button.disabled = false;
1538
+ if (button.textContent === 'Forking…') button.textContent = 'Fork';
1539
+ }
1348
1540
  }
1349
1541
 
1350
1542
  function appendStreamBubble(role) {
@@ -1622,10 +1814,12 @@ export function initChat() {
1622
1814
  $('stop-btn')?.classList.toggle('hidden', !val);
1623
1815
  const input = $('chat-input');
1624
1816
  if (input) {
1625
- input.disabled = false;
1626
- if (val) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
1817
+ input.disabled = !!ctx.externalSessionReadOnly;
1818
+ if (ctx.externalSessionReadOnly) input.placeholder = '外部终端会话 · 实时同步中(只读)';
1819
+ else if (val) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
1627
1820
  else updateInputMode(input.value || '');
1628
1821
  }
1822
+ if (!val) $('send-btn')?.toggleAttribute('disabled', !!ctx.externalSessionReadOnly);
1629
1823
  const msgs = $('messages');
1630
1824
  if (!msgs) return;
1631
1825
  const existing = $('typing-indicator');
@@ -1747,6 +1941,17 @@ export function initChat() {
1747
1941
  el.textContent = expanded ? '▲ Show less' : '▼ Show more';
1748
1942
  });
1749
1943
 
1944
+ delegate.on('click', '[data-copy-message]', (e, el) => {
1945
+ e.preventDefault();
1946
+ e.stopPropagation();
1947
+ void copyAssistantMessage(el);
1948
+ });
1949
+ delegate.on('click', '[data-fork-message]', (e, el) => {
1950
+ e.preventDefault();
1951
+ e.stopPropagation();
1952
+ void forkAssistantMessage(el);
1953
+ });
1954
+
1750
1955
  // Copy code from fenced blocks
1751
1956
  delegate.on('click', '.code-copy', async (e, el) => {
1752
1957
  e.preventDefault();
package/public/router.js CHANGED
@@ -54,6 +54,11 @@ export function applyInitialRoute() {
54
54
  }
55
55
 
56
56
  function applyHash(path) {
57
+ const pathname = String(path || '').split('?')[0];
58
+ if (pathname === '/settings') {
59
+ document.dispatchEvent(new CustomEvent('router:settings'));
60
+ return;
61
+ }
57
62
  // /session/:id or /session/:id/:tab
58
63
  const parsed = parseHashPath(path);
59
64
  if (!parsed) {