clay-server 2.40.0 → 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;
@@ -314,19 +327,38 @@ function attachSessions(ctx) {
314
327
  if (!session) return;
315
328
  if (session.vendor && session.vendor !== "claude") { session.tuiSuspended = false; return; }
316
329
  var pref = getClaudeOpenModeForWs(ws);
330
+ // A LIVE runtime always wins over the viewer's claudeOpenMode pref:
331
+ // another user (or this user in another tab) may be in the session right
332
+ // now, so we join it in whatever mode it is actually running and never
333
+ // convert or kill it. "tui stays tui, gui stays gui."
334
+ var liveNativePty = tm && typeof session.terminalId === "number" && tm.has(session.terminalId);
335
+ var liveResumePty = tm && typeof session.runtimeTerminalId === "number" && tm.has(session.runtimeTerminalId);
336
+ var liveSdk = !!session.queryInstance || !!session.isProcessing;
337
+ if (liveNativePty) {
338
+ session.runtimeMode = "tui";
339
+ session.runtimeTerminalId = session.terminalId;
340
+ session.tuiSuspended = false;
341
+ return;
342
+ }
343
+ if (liveResumePty) {
344
+ session.runtimeMode = "tui";
345
+ session.tuiSuspended = false;
346
+ return;
347
+ }
348
+ if (liveSdk) {
349
+ // Actively running as a GUI/SDK session - show GUI for everyone.
350
+ session.runtimeMode = (session.mode === "tui") ? "gui" : null;
351
+ session.runtimeTerminalId = null;
352
+ session.tuiSuspended = false;
353
+ return;
354
+ }
355
+ // Cold session: apply the viewer's pref.
317
356
  if (session.mode === "tui") {
318
357
  if (pref === "gui") {
319
358
  prepareTuiSessionForGuiView(session);
320
359
  session.runtimeMode = "gui";
321
360
  session.runtimeTerminalId = null;
322
361
  session.tuiSuspended = false;
323
- } else if (tm && typeof session.terminalId === "number" && tm.has(session.terminalId)) {
324
- session.runtimeMode = "tui";
325
- session.runtimeTerminalId = session.terminalId;
326
- session.tuiSuspended = false;
327
- } else if (tm && typeof session.runtimeTerminalId === "number" && tm.has(session.runtimeTerminalId)) {
328
- session.runtimeMode = "tui";
329
- session.tuiSuspended = false;
330
362
  } else {
331
363
  prepareTuiSessionForGuiView(session);
332
364
  session.runtimeMode = null;
@@ -334,31 +366,14 @@ function attachSessions(ctx) {
334
366
  session.tuiSuspended = true;
335
367
  }
336
368
  } else {
337
- // Born-GUI: reattach a live resume PTY if one exists; never spawn here.
338
- if (pref === "tui" && tm && typeof session.runtimeTerminalId === "number" && tm.has(session.runtimeTerminalId)) {
339
- session.runtimeMode = "tui";
340
- } else {
341
- session.runtimeMode = null;
342
- }
369
+ // Born-GUI: always GUI. We no longer auto-convert a GUI session to a
370
+ // `claude --resume` terminal on a pref=tui click - that hijacked shared
371
+ // GUI sessions in multi-user. Such sessions render as their SDK chat.
372
+ session.runtimeMode = null;
343
373
  session.tuiSuspended = false;
344
374
  }
345
375
  }
346
376
 
347
- // Compute the runtimeMode the client should render for this session given
348
- // the user's current preference. Pure function over session + pref; the
349
- // caller decides whether to also mutate state (spawn PTY, convert, etc.)
350
- // via the helpers above.
351
- function computeRuntimeMode(session, pref) {
352
- if (!session) return "gui";
353
- if (session.vendor && session.vendor !== "claude") return session.mode || "gui";
354
- var effPref = (pref === "gui") ? "gui" : "tui";
355
- if (session.mode === "tui") {
356
- return effPref === "gui" ? "gui" : "tui";
357
- }
358
- // session.mode === 'gui'
359
- return effPref === "tui" ? "tui" : "gui";
360
- }
361
-
362
377
  function handleSessionsMessage(ws, msg) {
363
378
 
364
379
  if (msg.type === "push_subscribe") {
@@ -568,33 +583,17 @@ function attachSessions(ctx) {
568
583
 
569
584
  if (msg.type === "switch_session") {
570
585
  if (msg.id && sm.sessions.has(msg.id)) {
571
- // Apply the claudeOpenMode preference to the target Claude session
572
- // before sm.switchSession fires session_switched. Two transforms:
573
- //
574
- // - born-GUI viewed under TUI pref: spawn a transient PTY running
575
- // `claude --resume <cliSessionId>`; the session record stays GUI
576
- // so a later pref flip back to GUI just hides the runtime link.
577
- // - born-TUI viewed under GUI pref: hydrate session.history from
578
- // the jsonl transcript and tear down the PTY so the session
579
- // renders via the SDK chat. The born-TUI marker stays on the
580
- // record so a later pref flip back to TUI restores the
581
- // embedded-terminal experience.
582
- //
583
- // runtimeMode / runtimeTerminalId are set on the session record so
584
- // the session_switched and session_list broadcasts surface them to
585
- // the client without sessions.js needing to know about the pref.
586
+ // resolveSessionForView sets runtimeMode / runtimeTerminalId /
587
+ // tuiSuspended (and hydrates the transcript) before sm.switchSession
588
+ // broadcasts session_switched. A live runtime keeps its actual mode
589
+ // for every viewer; only cold sessions follow the clicker's
590
+ // claudeOpenMode pref. Nothing is spawned here.
586
591
  var xmTarget = sm.sessions.get(msg.id);
587
592
  if (xmTarget && (xmTarget.vendor === "claude" || !xmTarget.vendor)) {
588
- var xmPref = getClaudeOpenModeForWs(ws);
589
- // Born-GUI under TUI pref with no live resume PTY: spawn a transient
590
- // `claude --resume` now (only switch_session spawns; born-TUI sessions
591
- // stay lazy and the connect path never spawns). resolveSessionForView
592
- // then turns the live PTY into runtimeMode='tui'.
593
- if (xmTarget.mode === "gui" && xmTarget.cliSessionId &&
594
- computeRuntimeMode(xmTarget, xmPref) === "tui" &&
595
- !(tm && typeof xmTarget.runtimeTerminalId === "number" && tm.has(xmTarget.runtimeTerminalId))) {
596
- spawnRuntimeTuiPty(xmTarget, ws);
597
- }
593
+ // Single source of truth: live runtime wins (tui stays tui, gui stays
594
+ // gui); only cold sessions follow the viewer's pref. No PTY is spawned
595
+ // on switch - born-TUI resumes lazily via the Resume bar
596
+ // (resume_tui_session), and born-GUI never auto-converts to a terminal.
598
597
  resolveSessionForView(xmTarget, ws);
599
598
  }
600
599
  // If the target session's vendor doesn't own the currently cached
@@ -694,6 +693,31 @@ function attachSessions(ctx) {
694
693
  return true;
695
694
  }
696
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
+
697
721
  if (msg.type === "set_mate_dm") {
698
722
  // Only store mateDm on non-mate projects (main project presence).
699
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) {