clay-server 2.40.1-beta.1 → 2.41.0-beta.1

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/lib/daemon.js CHANGED
@@ -730,7 +730,7 @@ var relay = createServer({
730
730
  },
731
731
  onSetTerminalFont: function (family, size) {
732
732
  var DEFAULT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace";
733
- var current = config.terminalFont || { family: DEFAULT_FAMILY, size: 13 };
733
+ var current = config.terminalFont || { family: DEFAULT_FAMILY, size: 14 };
734
734
  var nextFamily = (typeof family === "string" && family.trim()) ? family.trim().slice(0, 200) : current.family;
735
735
  var nextSize = current.size;
736
736
  if (typeof size === "number" && size >= 9 && size <= 32) {
@@ -176,6 +176,19 @@ function attachSessions(ctx) {
176
176
  cliSessionId: s.cliSessionId || null,
177
177
  });
178
178
  } catch (e) {}
179
+ // Re-read the assistant text index and broadcast it so any client
180
+ // with this session in view can wire hover-to-grab onto the new
181
+ // message right away. The transcript is small enough that a full
182
+ // re-send beats maintaining a delta protocol.
183
+ try {
184
+ var newIndex = require("./tui-transcript-index").readAssistantIndex(cwd, s.cliSessionId);
185
+ send({
186
+ type: "tui_transcript_state",
187
+ id: s.localId,
188
+ cliSessionId: s.cliSessionId,
189
+ messages: newIndex.messages,
190
+ });
191
+ } catch (e) {}
179
192
  },
180
193
  });
181
194
  session._titleWatcherStop = stop;
@@ -680,6 +693,31 @@ function attachSessions(ctx) {
680
693
  return true;
681
694
  }
682
695
 
696
+ // Client asks for the assistant text index of a Claude TUI session so
697
+ // it can wire hover-to-grab on the rendered terminal output. Only the
698
+ // raw markdown of assistant text messages is returned — no tool calls,
699
+ // no user prompts. Codex sessions skip the feature.
700
+ if (msg.type === "tui_transcript_request") {
701
+ var tprId = msg.id;
702
+ var tprSess = (tprId && sm.sessions.has(tprId)) ? sm.sessions.get(tprId) : null;
703
+ if (!tprSess || !tprSess.cliSessionId || tprSess.mode !== "tui") return true;
704
+ if (tprSess.vendor && tprSess.vendor !== "claude") return true;
705
+ if (usersModule.isMultiUser() && ws._clayUser
706
+ && !usersModule.canAccessSession(ws._clayUser.id, tprSess, { visibility: "public" })) {
707
+ return true;
708
+ }
709
+ try {
710
+ var tprIndex = require("./tui-transcript-index").readAssistantIndex(cwd, tprSess.cliSessionId);
711
+ sendTo(ws, {
712
+ type: "tui_transcript_state",
713
+ id: tprId,
714
+ cliSessionId: tprSess.cliSessionId,
715
+ messages: tprIndex.messages,
716
+ });
717
+ } catch (e) {}
718
+ return true;
719
+ }
720
+
683
721
  if (msg.type === "set_mate_dm") {
684
722
  // Only store mateDm on non-mate projects (main project presence).
685
723
  // Mate projects should never hold mateDm to avoid circular restore loops.
@@ -22,6 +22,9 @@
22
22
  SF Mono is Apple-system-only (no Google Fonts entry) and
23
23
  ui-monospace is a system keyword - both rely on the user's OS. -->
24
24
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Fira+Code:wght@400;500;700&family=Cascadia+Code:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500;700&family=Source+Code+Pro:wght@400;500;700&display=swap" rel="stylesheet">
25
+ <!-- D2 Coding isn't on Google Fonts and its CJK woff2 is ~1.4MB, so it
26
+ is lazy-loaded by terminal-prefs.js the first time the user selects
27
+ it (or boots with it already saved). See ensureD2CodingWebfont(). -->
25
28
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css">
26
29
  <script>
27
30
  (function(){try{var k="clay-theme-vars",v=localStorage.getItem(k),r=document.documentElement;if(v){var o=JSON.parse(v),p;for(p in o)r.style.setProperty(p,o[p]);var vt=localStorage.getItem(k.replace("-vars","-variant"));if(vt==="light"){r.classList.add("light-theme");r.classList.remove("dark-theme")}else{r.classList.add("dark-theme");r.classList.remove("light-theme")}var m=document.querySelector('meta[name="theme-color"]');if(m&&o["--bg"])m.setAttribute("content",o["--bg"])}else{var sl=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches;if(sl){r.classList.add("light-theme");r.classList.remove("dark-theme")}}}catch(e){}})();
@@ -28,6 +28,7 @@ import { isProjectSettingsOpen, handleInstructionsRead, handleInstructionsWrite,
28
28
  import { updateSettingsModels, updateSettingsStats, updateDaemonConfig, handleSetPinResult, handleKeepAwakeChanged, handleAutoContinueChanged, handleRestartResult, handleShutdownResult, handleSharedEnv, handleSharedEnvSaved, handleGlobalClaudeMdRead, handleGlobalClaudeMdWrite } from './server-settings.js';
29
29
  import { handleTermList, handleTermCreated, sendTerminalCommand, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed } from './terminal.js';
30
30
  import { attachTuiView, detachTuiView, setTuiSuspendedView, tuiHandleTermOutput, tuiHandleTermResized, tuiHandleTermExited, tuiHandleTermClosed } from './session-tui-view.js';
31
+ import { handleTuiTranscriptState } from './tui-grab.js';
31
32
  import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed } from './tui-attention.js';
32
33
  import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult, handleEmailDefaults } from './context-sources.js';
33
34
  import { refreshEmailSettings } from './user-settings.js';
@@ -421,6 +422,12 @@ export function processMessage(msg) {
421
422
  }) });
422
423
  break;
423
424
 
425
+ case "tui_transcript_state":
426
+ // Assistant text index for a Claude TUI session — drives the
427
+ // hover-to-grab overlay in lib/public/modules/tui-grab.js.
428
+ handleTuiTranscriptState(msg);
429
+ break;
430
+
424
431
  case "model_info": {
425
432
  // Drop stale model_info from a vendor that doesn't match the active
426
433
  // session's vendor. On high-latency connections, the server's default-
@@ -616,7 +623,7 @@ export function processMessage(msg) {
616
623
  // `claude` inside a real PTY. Mount or tear down before the rest of
617
624
  // the chat-side bookkeeping runs so we don't waste work on hidden DOM.
618
625
  if (_effectiveMode === "tui" && typeof _effectiveTerminalId === "number") {
619
- attachTuiView(_effectiveTerminalId);
626
+ attachTuiView(_effectiveTerminalId, msg.id, msg.vendor || null);
620
627
  } else {
621
628
  detachTuiView();
622
629
  }
@@ -7,17 +7,31 @@ import { escapeHtml } from './utils.js';
7
7
  import { store } from './store.js';
8
8
  import { getWs } from './ws-ref.js';
9
9
  import { openDm } from './app-dm.js';
10
- import { getCachedProjects } from './app-projects.js';
11
- import { switchProject } from './app-projects.js';
10
+ import { getCachedProjects, switchProject, renderProjectList } from './app-projects.js';
12
11
  import { mateAvatarUrl, userAvatarUrl } from './avatar.js';
13
12
  import { openTerminal } from './terminal.js';
14
13
  import { openTuiModal } from './tui-attention.js';
14
+ import { startUrgentBlink, stopUrgentBlink } from './app-favicon.js';
15
+ import { playDoneSound, isNotifSoundEnabled } from './notifications.js';
15
16
  var notifications = [];
16
17
  var unreadCount = 0;
17
18
  var bannerContainer = null;
18
19
  var bellBtn = null;
19
20
  var badgeEl = null;
20
21
 
22
+ // --- Pending TUI attention tracking ---
23
+ // Mirrors the icon-shake / favicon-blink behavior the SDK side already gets
24
+ // from `pendingPermissions` on project status broadcasts. The notification
25
+ // center is our source of truth for unresolved tui_attention notifications:
26
+ // when one arrives we bump the slug's count and start the urgent favicon
27
+ // blink; when it's dismissed (the user opened the session, clicked the
28
+ // banner, or closed it) we decrement and stop the blink once nothing is
29
+ // left pending. `notif.id -> slug` lets us decrement without having to
30
+ // re-derive the slug from a dismissed notification.
31
+ var pendingTuiBySlug = Object.create(null);
32
+ var pendingTuiBySlugId = Object.create(null);
33
+ var pendingTuiTotal = 0;
34
+
21
35
  // --- Update available banner state ---
22
36
  // Server pushes update_available on an hourly boundary; dismissal is
23
37
  // per-banner-instance and doesn't need to persist. The next server push
@@ -457,11 +471,61 @@ function dismissNotif(id) {
457
471
  // Message handlers
458
472
  // ========================================================
459
473
 
474
+ function isPendingTuiAttention(notif) {
475
+ return notif && notif.type === "tui_attention" && notif.slug;
476
+ }
477
+
478
+ function bumpTuiAttention(notif) {
479
+ if (!isPendingTuiAttention(notif)) return false;
480
+ if (pendingTuiBySlugId[notif.id]) return false;
481
+ pendingTuiBySlugId[notif.id] = notif.slug;
482
+ pendingTuiBySlug[notif.slug] = (pendingTuiBySlug[notif.slug] || 0) + 1;
483
+ pendingTuiTotal++;
484
+ return true;
485
+ }
486
+
487
+ function dropTuiAttentionById(id) {
488
+ var slug = pendingTuiBySlugId[id];
489
+ if (!slug) return false;
490
+ delete pendingTuiBySlugId[id];
491
+ pendingTuiBySlug[slug] = (pendingTuiBySlug[slug] || 1) - 1;
492
+ if (pendingTuiBySlug[slug] <= 0) delete pendingTuiBySlug[slug];
493
+ pendingTuiTotal--;
494
+ if (pendingTuiTotal < 0) pendingTuiTotal = 0;
495
+ return true;
496
+ }
497
+
498
+ function refreshTuiAttentionVisuals(prevTotal) {
499
+ if (pendingTuiTotal > 0 && prevTotal === 0) {
500
+ try { startUrgentBlink(); } catch (e) {}
501
+ } else if (pendingTuiTotal === 0 && prevTotal > 0) {
502
+ try { stopUrgentBlink(); } catch (e) {}
503
+ }
504
+ try { renderProjectList(); } catch (e) {}
505
+ }
506
+
507
+ export function getPendingTuiAttention(slug) {
508
+ if (!slug) return 0;
509
+ return pendingTuiBySlug[slug] || 0;
510
+ }
511
+
460
512
  export function handleNotificationsState(msg) {
461
513
  notifications = msg.notifications || [];
462
514
  unreadCount = msg.unreadCount || 0;
463
515
  updateBadge();
464
516
 
517
+ // Rebuild the pending-tui-attention map from the authoritative list. This
518
+ // also covers reconnect — pre-existing notifications get the icon shake
519
+ // and favicon blink restored without waiting for a fresh server event.
520
+ var prevTotal = pendingTuiTotal;
521
+ pendingTuiBySlug = Object.create(null);
522
+ pendingTuiBySlugId = Object.create(null);
523
+ pendingTuiTotal = 0;
524
+ for (var i = 0; i < notifications.length; i++) {
525
+ bumpTuiAttention(notifications[i]);
526
+ }
527
+ refreshTuiAttentionVisuals(prevTotal);
528
+
465
529
  // Check for pending session navigation after project switch
466
530
  try {
467
531
  var pendingSession = sessionStorage.getItem("pending-notif-session");
@@ -490,6 +554,20 @@ export function handleNotificationCreated(msg) {
490
554
  unreadCount = msg.unreadCount;
491
555
  updateBadge();
492
556
 
557
+ // tui_attention drives the project icon shake and the favicon blink —
558
+ // same visual cues the SDK already produces from pendingPermissions /
559
+ // permission_request messages, so the user notices either kind without
560
+ // having the right tab focused. Ring the ding too, gated on document.hidden
561
+ // and the notif-sound toggle to match the SDK 'done' path — we don't want
562
+ // to chime while the user is actively watching the tab.
563
+ var prevTotal = pendingTuiTotal;
564
+ if (bumpTuiAttention(notif)) {
565
+ refreshTuiAttentionVisuals(prevTotal);
566
+ if (document.hidden && isNotifSoundEnabled()) {
567
+ try { playDoneSound(); } catch (e) {}
568
+ }
569
+ }
570
+
493
571
  // user_mention banners are persistent (no auto-dismiss). They go away when:
494
572
  // 1. The user clicks the banner (which navigates to the session — see
495
573
  // banner click handler in showBanner / navigateToNotification), OR
@@ -516,6 +594,12 @@ export function handleNotificationDismissed(msg) {
516
594
  if (el) removeBanner(el);
517
595
  }
518
596
  }
597
+ var prevTotal = pendingTuiTotal;
598
+ var anyTuiDropped = false;
599
+ for (var j = 0; j < ids.length; j++) {
600
+ if (dropTuiAttentionById(ids[j])) anyTuiDropped = true;
601
+ }
602
+ if (anyTuiDropped) refreshTuiAttentionVisuals(prevTotal);
519
603
  }
520
604
 
521
605
  export function handleNotificationDismissedAll() {
@@ -523,6 +607,11 @@ export function handleNotificationDismissedAll() {
523
607
  unreadCount = 0;
524
608
  updateBadge();
525
609
  if (bannerContainer) bannerContainer.innerHTML = "";
610
+ var prevTotal = pendingTuiTotal;
611
+ pendingTuiBySlug = Object.create(null);
612
+ pendingTuiBySlugId = Object.create(null);
613
+ pendingTuiTotal = 0;
614
+ if (prevTotal > 0) refreshTuiAttentionVisuals(prevTotal);
526
615
  }
527
616
 
528
617
  // ========================================================
@@ -21,6 +21,7 @@ var FONT_OPTIONS = [
21
21
  { label: "IBM Plex Mono", family: "'IBM Plex Mono', 'SF Mono', Menlo, monospace" },
22
22
  { label: "Source Code Pro", family: "'Source Code Pro', 'SF Mono', Menlo, monospace" },
23
23
  { label: "Roboto Mono", family: "'Roboto Mono', 'SF Mono', Menlo, monospace" },
24
+ { label: "D2 Coding", family: "'D2 coding', 'D2Coding', 'SF Mono', Menlo, monospace" },
24
25
  { label: "System mono", family: "ui-monospace, monospace" },
25
26
  ];
26
27
 
@@ -19,6 +19,7 @@ import { store } from './store.js';
19
19
  import { getTerminalTheme } from './theme.js';
20
20
  import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
21
21
  import { showHeaderTuiFont, hideHeaderTuiFont } from './header-tui-font.js';
22
+ import { attachTuiGrab, detachTuiGrab } from './tui-grab.js';
22
23
  import { openArticle as openWhatsNewArticle } from './whats-new-article.js';
23
24
  import { createKeyToolbar, TERMINAL_TOOLBAR_HTML } from './terminal-toolbar.js';
24
25
  import { refreshIcons, iconHtml } from './icons.js';
@@ -593,7 +594,7 @@ function teardownXterm() {
593
594
  imeLastComposedAt = 0;
594
595
  }
595
596
 
596
- export function attachTuiView(terminalId) {
597
+ export function attachTuiView(terminalId, localId, vendor) {
597
598
  if (typeof terminalId !== "number") return;
598
599
  // Re-attaching to the same terminal: just refit and refocus.
599
600
  if (currentTermId === terminalId && xterm) {
@@ -603,6 +604,9 @@ export function attachTuiView(terminalId) {
603
604
  showHeaderTuiClose();
604
605
  fitNow();
605
606
  focusGuiComposer();
607
+ // Re-arm hover-grab against the same xterm in case the session
608
+ // metadata (localId / vendor) changed since the last attach.
609
+ if (localId) attachTuiGrab(xterm, xtermContainerEl, localId, { vendor: vendor });
606
610
  return;
607
611
  }
608
612
  // Switching to a different TUI terminal: tear down the old one cleanly.
@@ -619,6 +623,10 @@ export function attachTuiView(terminalId) {
619
623
  currentTermId = terminalId;
620
624
  if (!xterm) xterm = createXterm();
621
625
  if (!xterm) return;
626
+ // PC통신-style hover-and-click grab over the rendered output. Pulls
627
+ // its index from the on-disk JSONL transcript via tui_transcript_*
628
+ // messages; no-ops for Codex sessions.
629
+ if (localId) attachTuiGrab(xterm, xtermContainerEl, localId, { vendor: vendor });
622
630
 
623
631
  // Subscribe to the terminal's output stream on the server. The server
624
632
  // replays its scrollback buffer on attach so we never start blank.
@@ -653,6 +661,7 @@ export function detachTuiView() {
653
661
  try { resizeObserver.disconnect(); } catch (e) {}
654
662
  resizeObserver = null;
655
663
  }
664
+ detachTuiGrab();
656
665
  if (currentTermId != null) {
657
666
  var ws = getWs();
658
667
  if (ws && ws.readyState === 1) {
@@ -12,6 +12,7 @@ import { closeSidebar } from './sidebar.js';
12
12
  import { showIconTooltip, hideIconTooltip, closeUserCtxMenu, getCurrentDmUserId } from './sidebar-mates.js';
13
13
  import { switchProject, openAddProjectModal, getCachedProjects } from './app-projects.js';
14
14
  import { showHomeHub } from './app-home-hub.js';
15
+ import { getPendingTuiAttention } from './app-notifications.js';
15
16
 
16
17
  // --- Project state ---
17
18
  var cachedProjectList = [];
@@ -1011,7 +1012,11 @@ function createIconItem(p, currentSlug) {
1011
1012
  }
1012
1013
  el.appendChild(projectBadge);
1013
1014
 
1014
- if (p.pendingPermissions > 0 && !isActive) {
1015
+ // TUI sessions don't go through pendingPermissions on the project status
1016
+ // broadcast — they surface via tui_attention notifications. We shake the
1017
+ // same icon for either, so the user notices regardless of which engine
1018
+ // is asking.
1019
+ if (!isActive && (p.pendingPermissions > 0 || getPendingTuiAttention(p.slug) > 0)) {
1015
1020
  el.classList.add("has-pending-perm");
1016
1021
  }
1017
1022
 
@@ -1312,7 +1317,7 @@ export function renderIconStrip(projects, currentSlug) {
1312
1317
  });
1313
1318
  }
1314
1319
 
1315
- if (wt.pendingPermissions > 0 && !isWtActive) {
1320
+ if (!isWtActive && (wt.pendingPermissions > 0 || getPendingTuiAttention(wt.slug) > 0)) {
1316
1321
  wtEl.classList.add("has-pending-perm");
1317
1322
  }
1318
1323
 
@@ -1322,7 +1327,10 @@ export function renderIconStrip(projects, currentSlug) {
1322
1327
 
1323
1328
  var hasWtPendingPerm = false;
1324
1329
  for (var wpi2 = 0; wpi2 < worktrees.length; wpi2++) {
1325
- if (worktrees[wpi2].pendingPermissions > 0) { hasWtPendingPerm = true; break; }
1330
+ if (worktrees[wpi2].pendingPermissions > 0 || getPendingTuiAttention(worktrees[wpi2].slug) > 0) {
1331
+ hasWtPendingPerm = true;
1332
+ break;
1333
+ }
1326
1334
  }
1327
1335
  if (hasWtPendingPerm) folder.classList.remove("collapsed");
1328
1336
 
@@ -10,12 +10,60 @@
10
10
  // without round-trips.
11
11
 
12
12
  var DEFAULT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace";
13
- var DEFAULT_SIZE = 13;
13
+ var DEFAULT_SIZE = 14;
14
14
 
15
15
  var currentFamily = DEFAULT_FAMILY;
16
16
  var currentSize = DEFAULT_SIZE;
17
17
  var listeners = [];
18
18
 
19
+ // D2 Coding ships ~1.4MB of CJK glyphs per weight, so we avoid loading
20
+ // it eagerly on every page. The link is injected on demand the first
21
+ // time the user actually picks the font (or boots with it saved). The
22
+ // @font-face in this CSS registers under the family 'D2 coding' and
23
+ // also picks up a locally-installed 'D2Coding' via local() fallback.
24
+ //
25
+ // xterm's WebGL renderer measures cell metrics synchronously when the
26
+ // font family option changes, so the first atlas build right after
27
+ // selection happens against the *fallback* (SF Mono for Latin, a system
28
+ // Hangul font for Korean). Each is measured with its own width, so
29
+ // Latin and Hangul cells fall out of the expected 1:2 ratio and lines
30
+ // stop lining up. After the woff2 actually arrives we replay the
31
+ // font-change notification so every listener rebuilds its atlas with
32
+ // the proper D2 Coding metrics for both scripts.
33
+ var d2CodingInjected = false;
34
+ function notifyFontListeners() {
35
+ for (var i = 0; i < listeners.length; i++) {
36
+ try { listeners[i](currentFamily, currentSize); } catch (e) {}
37
+ }
38
+ }
39
+ function ensureD2CodingWebfont() {
40
+ if (d2CodingInjected) return;
41
+ d2CodingInjected = true;
42
+ if (typeof document === "undefined" || !document.head) return;
43
+ var link = document.createElement("link");
44
+ link.rel = "stylesheet";
45
+ link.href = "https://cdn.jsdelivr.net/gh/Joungkyun/font-d2coding/d2coding.css";
46
+ link.onload = function () {
47
+ if (document.fonts && typeof document.fonts.load === "function") {
48
+ Promise.all([
49
+ document.fonts.load('400 16px "D2 coding"'),
50
+ document.fonts.load('700 16px "D2 coding"'),
51
+ ]).then(notifyFontListeners).catch(function () {});
52
+ } else {
53
+ notifyFontListeners();
54
+ }
55
+ };
56
+ document.head.appendChild(link);
57
+ }
58
+
59
+ function maybeLazyLoadWebfont(family) {
60
+ if (typeof family !== "string") return;
61
+ var lower = family.toLowerCase();
62
+ if (lower.indexOf("d2 coding") !== -1 || lower.indexOf("d2coding") !== -1) {
63
+ ensureD2CodingWebfont();
64
+ }
65
+ }
66
+
19
67
  export function getTerminalFontFamily() {
20
68
  return currentFamily || DEFAULT_FAMILY;
21
69
  }
@@ -43,9 +91,8 @@ export function applyTerminalFont(family, size) {
43
91
  changed = true;
44
92
  }
45
93
  if (!changed) return;
46
- for (var i = 0; i < listeners.length; i++) {
47
- try { listeners[i](currentFamily, currentSize); } catch (e) {}
48
- }
94
+ maybeLazyLoadWebfont(currentFamily);
95
+ notifyFontListeners();
49
96
  }
50
97
 
51
98
  export function onTerminalFontChange(fn) {