clay-server 2.39.0-beta.4 → 2.39.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/lib/daemon.js CHANGED
@@ -728,6 +728,19 @@ var relay = createServer({
728
728
  console.log("[daemon] Chat layout:", val, "(web)");
729
729
  return { ok: true, chatLayout: val };
730
730
  },
731
+ onSetTerminalFont: function (family, size) {
732
+ var DEFAULT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace";
733
+ var current = config.terminalFont || { family: DEFAULT_FAMILY, size: 13 };
734
+ var nextFamily = (typeof family === "string" && family.trim()) ? family.trim().slice(0, 200) : current.family;
735
+ var nextSize = current.size;
736
+ if (typeof size === "number" && size >= 9 && size <= 32) {
737
+ nextSize = Math.round(size);
738
+ }
739
+ config.terminalFont = { family: nextFamily, size: nextSize };
740
+ saveConfig(config);
741
+ console.log("[daemon] Terminal font:", nextFamily, nextSize);
742
+ return config.terminalFont;
743
+ },
731
744
  onSetMateOnboarded: function () {
732
745
  config.mateOnboardingShown = true;
733
746
  saveConfig(config);
@@ -1119,35 +1119,6 @@
1119
1119
  margin-left: 12px;
1120
1120
  }
1121
1121
 
1122
- /* TUI session indicator: a tiny black "screen" with a bold green prompt
1123
- glyph, meant to read as a miniature terminal (CRT-style cursor) at
1124
- sidebar scale. Aligned to the middle of the text x-height so it sits
1125
- visually centered with the session title rather than dropping below. */
1126
- .session-tui-icon {
1127
- display: inline-flex;
1128
- align-items: center;
1129
- justify-content: center;
1130
- width: 14px;
1131
- height: 14px;
1132
- border-radius: 3px;
1133
- background: #000;
1134
- margin-right: 6px;
1135
- vertical-align: middle;
1136
- flex-shrink: 0;
1137
- /* Nudge upward a hair so the icon optical-centers with lowercase glyphs
1138
- (lucide icons render slightly below the geometric center). */
1139
- position: relative;
1140
- top: -1px;
1141
- }
1142
- .session-tui-icon svg,
1143
- .session-tui-icon .lucide {
1144
- width: 10px;
1145
- height: 10px;
1146
- color: #50fa7b;
1147
- stroke: #50fa7b;
1148
- stroke-width: 3;
1149
- }
1150
-
1151
1122
  .session-top-action:hover {
1152
1123
  background: var(--sidebar-hover);
1153
1124
  color: var(--text);
@@ -1,3 +1,13 @@
1
+ /* Subtle 1px divider against the sidebar so the embedded terminal
2
+ doesn't visually bleed into the session list. The host is
3
+ position:fixed and anchored to #messages' bounding rect, so its
4
+ left edge sits exactly on the sidebar boundary. Color matches
5
+ #main-area's existing border-left so the line reads as one
6
+ continuous edge with the rest of the chrome. */
7
+ #tui-session-host {
8
+ border-left: 1px solid var(--border-subtle);
9
+ }
10
+
1
11
  /* Policy notice that sits above the embedded xterm in fullscreen TUI
2
12
  sessions, explaining why this mode exists (post-2026-06-15 Agent SDK
3
13
  billing split). Thin so it doesn't eat terminal real estate. */
@@ -859,3 +859,46 @@
859
859
  align-items: stretch;
860
860
  }
861
861
  }
862
+
863
+ /* Terminal font picker row */
864
+ .us-term-font-row {
865
+ display: flex;
866
+ align-items: center;
867
+ gap: 8px;
868
+ flex-wrap: wrap;
869
+ }
870
+ .us-term-font-family {
871
+ flex: 1 1 auto;
872
+ min-width: 180px;
873
+ padding: 7px 10px;
874
+ border: 1px solid var(--border);
875
+ border-radius: 8px;
876
+ background: var(--input-bg);
877
+ color: var(--text);
878
+ font-size: 13px;
879
+ }
880
+ .us-term-font-custom {
881
+ flex: 1 1 100%;
882
+ padding: 7px 10px;
883
+ border: 1px solid var(--border);
884
+ border-radius: 8px;
885
+ background: var(--input-bg);
886
+ color: var(--text);
887
+ font-size: 13px;
888
+ font-family: "Roboto Mono", monospace;
889
+ }
890
+ .us-term-font-custom.hidden { display: none; }
891
+ .us-term-font-size {
892
+ width: 64px;
893
+ padding: 7px 10px;
894
+ border: 1px solid var(--border);
895
+ border-radius: 8px;
896
+ background: var(--input-bg);
897
+ color: var(--text);
898
+ font-size: 13px;
899
+ text-align: center;
900
+ }
901
+ .us-term-font-unit {
902
+ font-size: 13px;
903
+ color: var(--text-dimmer);
904
+ }
@@ -879,7 +879,7 @@
879
879
  </div>
880
880
  </div>
881
881
  <div class="user-island-actions">
882
- <button id="user-theme-toggle-btn" title="Switch theme" aria-label="Switch theme"><i data-lucide="moon"></i></button>
882
+ <button id="user-theme-toggle-btn" title="Themes" aria-label="Themes"><i data-lucide="palette"></i></button>
883
883
  <button id="user-settings-btn" title="User settings"><i data-lucide="settings"></i></button>
884
884
  </div>
885
885
  </div>
@@ -1002,18 +1002,22 @@
1002
1002
  <div class="us-section" data-section="us-appearance">
1003
1003
  <h2>Appearance</h2>
1004
1004
  <div class="settings-card">
1005
- <div class="settings-label" style="margin-bottom:10px;">Theme</div>
1006
- <div class="layout-switcher" id="us-theme-switcher">
1007
- <button class="layout-option" data-theme="light">
1008
- <span class="layout-option-icon">☀️</span>
1009
- <span class="layout-option-label">Light</span>
1010
- <span class="layout-option-desc">For people who open curtains. Bright, clean, productive.</span>
1011
- </button>
1012
- <button class="layout-option" data-theme="dark">
1013
- <span class="layout-option-icon">🌙</span>
1014
- <span class="layout-option-label">Dark</span>
1015
- <span class="layout-option-desc">For those who thrive after sunset. Your screen, your cave.</span>
1016
- </button>
1005
+ <div class="settings-label" style="margin-bottom:10px;">Terminal font</div>
1006
+ <div class="us-term-font-row">
1007
+ <select id="us-term-font-family" class="us-term-font-family">
1008
+ <option value="'SF Mono', Menlo, Monaco, 'Courier New', monospace">SF Mono / Menlo (default)</option>
1009
+ <option value="'JetBrains Mono', 'SF Mono', Menlo, monospace">JetBrains Mono</option>
1010
+ <option value="'Fira Code', 'SF Mono', Menlo, monospace">Fira Code</option>
1011
+ <option value="'Cascadia Code', 'SF Mono', Menlo, monospace">Cascadia Code</option>
1012
+ <option value="'IBM Plex Mono', 'SF Mono', Menlo, monospace">IBM Plex Mono</option>
1013
+ <option value="'Source Code Pro', 'SF Mono', Menlo, monospace">Source Code Pro</option>
1014
+ <option value="'Roboto Mono', 'SF Mono', Menlo, monospace">Roboto Mono</option>
1015
+ <option value="ui-monospace, monospace">System monospace</option>
1016
+ <option value="__custom__">Custom...</option>
1017
+ </select>
1018
+ <input type="text" id="us-term-font-family-custom" class="us-term-font-custom hidden" placeholder="e.g. 'My Font', monospace" maxlength="200">
1019
+ <input type="number" id="us-term-font-size" class="us-term-font-size" min="9" max="32" step="1">
1020
+ <span class="us-term-font-unit">px</span>
1017
1021
  </div>
1018
1022
  </div>
1019
1023
  <div class="settings-card">
@@ -16,6 +16,8 @@
16
16
 
17
17
  import { getWs } from './ws-ref.js';
18
18
  import { store } from './store.js';
19
+ import { getTerminalTheme } from './theme.js';
20
+ import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
19
21
  import { openArticle as openWhatsNewArticle } from './whats-new-article.js';
20
22
 
21
23
  // Stable id of the canonical "Why TUI mode?" article in
@@ -23,33 +25,9 @@ import { openArticle as openWhatsNewArticle } from './whats-new-article.js';
23
25
  // opens that article in the blog viewer instead of a one-off modal.
24
26
  var TUI_POLICY_ARTICLE_ID = "2026-06-tui-default";
25
27
 
26
- // Claude TUI sessions intentionally ignore Clay's dark/light theme and
27
- // always render with a classic black terminal look. The bottom-panel
28
- // shell terminal still follows the theme; this is specific to the TUI
29
- // session view so `claude` renders consistently across themes.
30
- var TUI_TERMINAL_THEME = {
31
- background: "#000000",
32
- foreground: "#e5e5e5",
33
- cursor: "#e5e5e5",
34
- cursorAccent: "#000000",
35
- selectionBackground: "#3a3a3a",
36
- black: "#000000",
37
- red: "#cd3131",
38
- green: "#0dbc79",
39
- yellow: "#e5e510",
40
- blue: "#2472c8",
41
- magenta: "#bc3fbc",
42
- cyan: "#11a8cd",
43
- white: "#e5e5e5",
44
- brightBlack: "#666666",
45
- brightRed: "#f14c4c",
46
- brightGreen: "#23d18b",
47
- brightYellow: "#f5f543",
48
- brightBlue: "#3b8eea",
49
- brightMagenta: "#d670d6",
50
- brightCyan: "#29b8db",
51
- brightWhite: "#ffffff",
52
- };
28
+ // Claude TUI session terminal colors follow Clay's active theme via
29
+ // getTerminalTheme(). Live theme switches are wired through
30
+ // setTuiSessionTheme() below, which theme.js calls from applyTheme.
53
31
 
54
32
  var hostEl = null; // container div mounted over #messages
55
33
  var xtermContainerEl = null;
@@ -165,25 +143,73 @@ function ensureHostEl() {
165
143
  xtermContainerEl.style.position = "relative";
166
144
  hostEl.appendChild(xtermContainerEl);
167
145
 
168
- // Paste-image handling. xterm.js natively pastes text but ignores
169
- // image/file clipboard payloads, so an image copied in Finder/Preview
170
- // would silently drop on Cmd+V. We intercept in the capture phase
171
- // (before xterm's hidden textarea handles the event), upload the
172
- // image to /api/upload, and inject the returned absolute path into
173
- // the PTY. Claude CLI accepts file paths as prompt input - the user
174
- // can then submit with Enter.
146
+ // Paste-image handling. Two paths cover the common platforms:
175
147
  //
176
- // Capture phase + stopImmediatePropagation on image hits is what
177
- // keeps xterm from ALSO pasting the file's text representation
178
- // (Finder copies leave a "filename.png" string in text/plain).
179
- // Text-only paste falls through so xterm's built-in text paste keeps
180
- // working unchanged.
148
+ // 1. `paste` event in capture phase - covers Cmd+V on macOS and
149
+ // Ctrl+V on Win/Linux, where the browser fires a paste event
150
+ // with clipboardData populated.
151
+ // 2. `keydown` for Ctrl+V (no Meta) in capture phase - macOS does
152
+ // not fire `paste` for Ctrl+V (Ctrl isn't the OS paste modifier
153
+ // there), so the keystroke would otherwise reach claude CLI
154
+ // directly. We read the system clipboard via the async
155
+ // Clipboard API instead.
156
+ //
157
+ // In both paths an image hit is uploaded to /api/upload (same
158
+ // endpoint the GUI input uses) and the returned absolute path is
159
+ // injected as text into the PTY; claude CLI accepts file paths as
160
+ // prompt input. Capture phase + stopImmediatePropagation keep xterm
161
+ // from also processing the event (which would paste the filename
162
+ // text alongside the path).
181
163
  hostEl.addEventListener("paste", handleTuiPaste, true);
164
+ hostEl.addEventListener("keydown", handleTuiCtrlV, true);
182
165
 
183
166
  document.body.appendChild(hostEl);
184
167
  return hostEl;
185
168
  }
186
169
 
170
+ function handleTuiCtrlV(e) {
171
+ // Only handle plain Ctrl+V (no Meta/Alt/Shift). Cmd+V on macOS is
172
+ // covered by the paste event handler. Without this intercept on macOS
173
+ // a Ctrl+V keystroke would just be forwarded to claude CLI's own
174
+ // paste path, which can't read image data out of the system
175
+ // clipboard reliably ("no image found in clipboard").
176
+ if (e.key !== "v" || !e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
177
+ if (currentTermId == null) return;
178
+ if (!navigator.clipboard || typeof navigator.clipboard.read !== "function") return;
179
+
180
+ // Stop the keystroke from also reaching xterm / the PTY. We take full
181
+ // ownership of paste semantics: image -> upload + inject path, text
182
+ // -> forward via term_input, nothing -> no-op.
183
+ e.preventDefault();
184
+ e.stopImmediatePropagation();
185
+
186
+ navigator.clipboard.read().then(function (items) {
187
+ for (var i = 0; i < items.length; i++) {
188
+ var imgType = null;
189
+ for (var t = 0; t < items[i].types.length; t++) {
190
+ if (items[i].types[t].indexOf("image/") === 0) { imgType = items[i].types[t]; break; }
191
+ }
192
+ if (imgType) {
193
+ items[i].getType(imgType).then(uploadAndInjectPath).catch(function () {});
194
+ return; // handle the first image found
195
+ }
196
+ }
197
+ // No image - fall back to text paste behavior.
198
+ if (typeof navigator.clipboard.readText === "function") {
199
+ navigator.clipboard.readText().then(function (text) {
200
+ if (!text || currentTermId == null) return;
201
+ var ws = getWs();
202
+ if (!ws || ws.readyState !== 1) return;
203
+ ws.send(JSON.stringify({ type: "term_input", id: currentTermId, data: text }));
204
+ }).catch(function () {});
205
+ }
206
+ }).catch(function () {
207
+ // Permission denied or unsupported - silently fail. xterm received
208
+ // no keystroke either because we already stopped propagation, so
209
+ // the user can press the key again to retry.
210
+ });
211
+ }
212
+
187
213
  function handleTuiPaste(e) {
188
214
  var cd = e.clipboardData;
189
215
  if (!cd || currentTermId == null) return;
@@ -293,14 +319,14 @@ function fitNow() {
293
319
 
294
320
  function createXterm() {
295
321
  if (typeof Terminal === "undefined") return null;
296
- var theme = TUI_TERMINAL_THEME;
322
+ var theme = getTerminalTheme();
297
323
  // Match the host background to the xterm theme so any sub-cell gap
298
324
  // between the last rendered row and the host's bottom blends in.
299
325
  if (hostEl) hostEl.style.background = theme.background;
300
326
  var term = new Terminal({
301
327
  cursorBlink: true,
302
- fontSize: 13,
303
- fontFamily: "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
328
+ fontSize: getTerminalFontSize(),
329
+ fontFamily: getTerminalFontFamily(),
304
330
  theme: theme,
305
331
  scrollback: 5000,
306
332
  });
@@ -450,3 +476,28 @@ export function tuiHandleTermClosed(msg) {
450
476
  export function getActiveTuiTerminalId() {
451
477
  return currentTermId;
452
478
  }
479
+
480
+ // Live theme update. Called by theme.js applyTheme() whenever the user
481
+ // switches themes - rewrites xterm colors in place and re-syncs the
482
+ // host background so transitions are seamless without re-mounting the
483
+ // PTY.
484
+ export function setTuiSessionTheme(xtermTheme) {
485
+ if (xterm) {
486
+ try { xterm.options.theme = xtermTheme; } catch (e) {}
487
+ }
488
+ if (hostEl && xtermTheme && xtermTheme.background) {
489
+ hostEl.style.background = xtermTheme.background;
490
+ }
491
+ }
492
+
493
+ // Live font update. Cell metrics shift with font size, so we refit
494
+ // after applying and let the existing resize debounce notify the PTY
495
+ // of the new cols/rows.
496
+ onTerminalFontChange(function (family, size) {
497
+ if (!xterm) return;
498
+ try {
499
+ if (family) xterm.options.fontFamily = family;
500
+ if (size) xterm.options.fontSize = size;
501
+ } catch (e) {}
502
+ scheduleResize();
503
+ });
@@ -1099,9 +1099,6 @@ function renderSessionItem(s) {
1099
1099
  if (store.get('isMultiUserMode') && s.sessionVisibility === "private") {
1100
1100
  textHtml += '<span class="session-private-icon" title="Private session">' + iconHtml("lock") + '</span>';
1101
1101
  }
1102
- if (s.mode === "tui") {
1103
- textHtml += '<span class="session-tui-icon" title="Claude Code terminal session">' + iconHtml("terminal") + '</span>';
1104
- }
1105
1102
  textHtml += highlightMatch(s.title || "New Session", searchQuery);
1106
1103
  textSpan.innerHTML = textHtml;
1107
1104
  el.appendChild(textSpan);
@@ -0,0 +1,53 @@
1
+ // terminal-prefs.js
2
+ //
3
+ // Single source of truth for terminal font preferences (family + size).
4
+ // Every xterm in Clay (bottom shell panel, Claude TUI session view, TUI
5
+ // attention modal) reads from here on create and re-applies on the
6
+ // `font-change` event so live updates are seamless.
7
+ //
8
+ // Persistence lives server-side under the user's profile. This module
9
+ // keeps a synchronized in-memory copy that the rest of the client reads
10
+ // without round-trips.
11
+
12
+ var DEFAULT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace";
13
+ var DEFAULT_SIZE = 13;
14
+
15
+ var currentFamily = DEFAULT_FAMILY;
16
+ var currentSize = DEFAULT_SIZE;
17
+ var listeners = [];
18
+
19
+ export function getTerminalFontFamily() {
20
+ return currentFamily || DEFAULT_FAMILY;
21
+ }
22
+
23
+ export function getTerminalFontSize() {
24
+ var n = Number(currentSize);
25
+ return (n >= 9 && n <= 32) ? n : DEFAULT_SIZE;
26
+ }
27
+
28
+ export function getDefaultTerminalFontFamily() {
29
+ return DEFAULT_FAMILY;
30
+ }
31
+
32
+ // Set the in-memory values and notify subscribers. Pass null/undefined
33
+ // for any field that should stay unchanged. Persisting to the server
34
+ // is the caller's responsibility - this only updates local UI.
35
+ export function applyTerminalFont(family, size) {
36
+ var changed = false;
37
+ if (typeof family === "string" && family.trim() && family !== currentFamily) {
38
+ currentFamily = family;
39
+ changed = true;
40
+ }
41
+ if (typeof size === "number" && size >= 9 && size <= 32 && size !== currentSize) {
42
+ currentSize = Math.round(size);
43
+ changed = true;
44
+ }
45
+ if (!changed) return;
46
+ for (var i = 0; i < listeners.length; i++) {
47
+ try { listeners[i](currentFamily, currentSize); } catch (e) {}
48
+ }
49
+ }
50
+
51
+ export function onTerminalFontChange(fn) {
52
+ if (typeof fn === "function") listeners.push(fn);
53
+ }
@@ -3,6 +3,7 @@ import { closeSidebar } from './sidebar.js';
3
3
  import { closeFileViewer } from './filebrowser.js';
4
4
  import { copyToClipboard } from './utils.js';
5
5
  import { getTerminalTheme } from './theme.js';
6
+ import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
6
7
 
7
8
  var ctx;
8
9
  var tabs = new Map(); // termId -> { id, title, exited, xterm, fitAddon, bodyEl }
@@ -347,8 +348,8 @@ function createXtermForTab(tab) {
347
348
 
348
349
  var xterm = new Terminal({
349
350
  cursorBlink: true,
350
- fontSize: 13,
351
- fontFamily: "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
351
+ fontSize: getTerminalFontSize(),
352
+ fontFamily: getTerminalFontFamily(),
352
353
  theme: getTerminalTheme(),
353
354
  });
354
355
 
@@ -807,6 +808,21 @@ export function setTerminalTheme(xtermTheme) {
807
808
  }
808
809
  }
809
810
 
811
+ // Live font update: applies family/size to every open shell terminal
812
+ // and refits each (cell dimensions change with font size, so cols/rows
813
+ // shift too). Hooked via onTerminalFontChange below.
814
+ function applyFontToAllTabs(family, size) {
815
+ for (var tab of tabs.values()) {
816
+ if (!tab.xterm) continue;
817
+ try {
818
+ if (family) tab.xterm.options.fontFamily = family;
819
+ if (size) tab.xterm.options.fontSize = size;
820
+ if (tab.fitAddon) tab.fitAddon.fit();
821
+ } catch (e) {}
822
+ }
823
+ }
824
+ onTerminalFontChange(applyFontToAllTabs);
825
+
810
826
  // --- Terminal context menu ---
811
827
  function closeTermCtxMenu() {
812
828
  if (termCtxMenu) {
@@ -1,5 +1,7 @@
1
1
  import { iconHtml, refreshIcons } from './icons.js';
2
2
  import { setTerminalTheme } from './terminal.js';
3
+ import { setTuiSessionTheme } from './session-tui-view.js';
4
+ import { setTuiAttentionTheme } from './tui-attention.js';
3
5
  import { updateMermaidTheme } from './markdown.js';
4
6
 
5
7
  // --- Color utilities ---
@@ -352,6 +354,8 @@ export function applyTheme(themeId, fromPicker) {
352
354
 
353
355
  var termTheme = computeTerminalTheme(theme);
354
356
  try { setTerminalTheme(termTheme); } catch (e) {}
357
+ try { setTuiSessionTheme(termTheme); } catch (e) {}
358
+ try { setTuiAttentionTheme(termTheme); } catch (e) {}
355
359
 
356
360
  var mermaidVars = computeMermaidVars(theme);
357
361
  try { updateMermaidTheme(mermaidVars); } catch (e) {}
@@ -538,15 +542,8 @@ function updateToggleIcon() {
538
542
  // User settings toggle
539
543
  var usToggle = document.getElementById("us-theme-toggle");
540
544
  if (usToggle) usToggle.checked = isLight;
541
- // User island toggle
542
- var islandToggle = document.getElementById("user-theme-toggle-btn");
543
- if (islandToggle) {
544
- islandToggle.classList.remove("theme-toggle-light", "theme-toggle-dark");
545
- islandToggle.classList.add(isLight ? "theme-toggle-light" : "theme-toggle-dark");
546
- islandToggle.innerHTML = iconHtml(isLight ? "moon" : "sun");
547
- islandToggle.title = isLight ? "Switch to dark mode" : "Switch to light mode";
548
- islandToggle.setAttribute("aria-label", islandToggle.title);
549
- }
545
+ // User island button is now a static "skins / themes" trigger; no
546
+ // icon swap on mode change.
550
547
  refreshIcons();
551
548
  }
552
549
 
@@ -749,24 +746,96 @@ export function initTheme() {
749
746
  var islandToggle = document.getElementById("user-theme-toggle-btn");
750
747
  if (islandToggle && !islandToggle._clayThemeBound) {
751
748
  islandToggle._clayThemeBound = true;
752
- islandToggle.addEventListener("click", function () {
753
- toggleDarkMode();
749
+ // Lock down static skin icon (independent of mode) and open the
750
+ // theme picker popover anchored to the button.
751
+ islandToggle.innerHTML = iconHtml("palette");
752
+ islandToggle.title = "Themes";
753
+ islandToggle.setAttribute("aria-label", "Themes");
754
+ refreshIcons();
755
+ islandToggle.addEventListener("click", function (e) {
756
+ e.stopPropagation();
757
+ toggleIslandThemePicker(islandToggle);
754
758
  });
755
759
  }
756
760
  }
757
761
 
758
- // --- Settings picker (for appearance section in server settings) ---
759
- export function openSettingsThemePicker(containerEl) {
760
- if (!containerEl) return;
762
+ // --- Floating popover anchored to the user island button ---
763
+ //
764
+ // Mounts the picker (built by createThemePicker) into document.body as a
765
+ // fixed-position overlay aligned under the anchor's right edge. Click
766
+ // outside or Escape closes it. The picker DOM is shared with any other
767
+ // embed point (settings page); we just reparent and reposition.
768
+
769
+ var islandPickerOpen = false;
770
+ var islandPickerOutsideHandler = null;
771
+ var islandPickerKeyHandler = null;
772
+
773
+ function positionPickerNearAnchor(anchorEl) {
774
+ if (!pickerEl || !anchorEl) return;
775
+ var rect = anchorEl.getBoundingClientRect();
776
+ var picker = pickerEl;
777
+ // Render hidden first to measure
778
+ picker.style.left = "0px";
779
+ picker.style.top = "0px";
780
+ var pw = picker.offsetWidth || 240;
781
+ var ph = picker.offsetHeight || 280;
782
+ var vw = window.innerWidth;
783
+ var vh = window.innerHeight;
784
+ // Right-align to anchor, drop below by 8px. Flip upward if not enough
785
+ // room below. Clamp into viewport on both axes.
786
+ var left = rect.right - pw;
787
+ if (left < 8) left = 8;
788
+ if (left + pw > vw - 8) left = vw - pw - 8;
789
+ var top = rect.bottom + 8;
790
+ if (top + ph > vh - 8) {
791
+ var flipped = rect.top - ph - 8;
792
+ if (flipped >= 8) top = flipped;
793
+ else top = Math.max(8, vh - ph - 8);
794
+ }
795
+ picker.style.left = left + "px";
796
+ picker.style.top = top + "px";
797
+ }
761
798
 
762
- if (!pickerEl) {
763
- createThemePicker();
799
+ function closeIslandThemePicker() {
800
+ if (!islandPickerOpen) return;
801
+ islandPickerOpen = false;
802
+ if (pickerEl) pickerEl.classList.remove("visible");
803
+ if (islandPickerOutsideHandler) {
804
+ document.removeEventListener("mousedown", islandPickerOutsideHandler, true);
805
+ islandPickerOutsideHandler = null;
806
+ }
807
+ if (islandPickerKeyHandler) {
808
+ document.removeEventListener("keydown", islandPickerKeyHandler, true);
809
+ islandPickerKeyHandler = null;
764
810
  }
811
+ }
765
812
 
766
- // Move picker into settings container if not already there
767
- if (pickerEl.parentNode !== containerEl) {
768
- containerEl.innerHTML = "";
769
- containerEl.appendChild(pickerEl);
813
+ export function toggleIslandThemePicker(anchorEl) {
814
+ if (islandPickerOpen) {
815
+ closeIslandThemePicker();
816
+ return;
817
+ }
818
+ if (!pickerEl) createThemePicker();
819
+ if (pickerEl.parentNode !== document.body) {
820
+ document.body.appendChild(pickerEl);
770
821
  }
822
+ positionPickerNearAnchor(anchorEl);
771
823
  pickerEl.classList.add("visible");
824
+ islandPickerOpen = true;
825
+
826
+ // Click anywhere outside picker (and not the anchor) closes it. Use
827
+ // capture phase + mousedown so the click that opened the picker
828
+ // doesn't immediately close it.
829
+ islandPickerOutsideHandler = function (ev) {
830
+ if (!pickerEl) return;
831
+ if (pickerEl.contains(ev.target)) return;
832
+ if (anchorEl && anchorEl.contains(ev.target)) return;
833
+ closeIslandThemePicker();
834
+ };
835
+ document.addEventListener("mousedown", islandPickerOutsideHandler, true);
836
+
837
+ islandPickerKeyHandler = function (ev) {
838
+ if (ev.key === "Escape") closeIslandThemePicker();
839
+ };
840
+ document.addEventListener("keydown", islandPickerKeyHandler, true);
772
841
  }
@@ -13,13 +13,12 @@
13
13
  // term_attach / term_input / term_output through that connection only.
14
14
  // The main WS is untouched.
15
15
 
16
- var TUI_TERMINAL_THEME = {
17
- background: "#000000",
18
- foreground: "#e5e5e5",
19
- cursor: "#e5e5e5",
20
- cursorAccent: "#000000",
21
- selectionBackground: "#3a3a3a",
22
- };
16
+ import { getTerminalTheme } from './theme.js';
17
+ import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
18
+
19
+ // TUI attention modal xterm follows Clay's active theme via
20
+ // getTerminalTheme() and live-updates through setTuiAttentionTheme()
21
+ // which theme.js calls from applyTheme.
23
22
 
24
23
  var modalEl = null;
25
24
  var modalXterm = null;
@@ -152,9 +151,9 @@ export function openTuiModal(terminalId, sourceSlug, info) {
152
151
  bodyEl.innerHTML = "";
153
152
  modalXterm = new Terminal({
154
153
  cursorBlink: true,
155
- fontSize: 13,
156
- fontFamily: "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
157
- theme: TUI_TERMINAL_THEME,
154
+ fontSize: getTerminalFontSize(),
155
+ fontFamily: getTerminalFontFamily(),
156
+ theme: getTerminalTheme(),
158
157
  scrollback: 5000,
159
158
  });
160
159
  if (typeof FitAddon !== "undefined") {
@@ -260,3 +259,29 @@ export function tuiModalHandleTermOutput() { return false; }
260
259
  export function tuiModalHandleTermResized() { return false; }
261
260
  export function tuiModalHandleTermExited() { return false; }
262
261
  export function tuiModalHandleTermClosed() { return false; }
262
+
263
+ // Live theme update for the attention modal. Called by theme.js when
264
+ // the user switches themes. Also retints the surrounding modal chrome
265
+ // so the frame doesn't stay black when a light theme is active.
266
+ export function setTuiAttentionTheme(xtermTheme) {
267
+ if (modalXterm) {
268
+ try { modalXterm.options.theme = xtermTheme; } catch (e) {}
269
+ }
270
+ if (modalEl && xtermTheme && xtermTheme.background) {
271
+ var bodyEl = modalEl.querySelector(".tui-modal-body");
272
+ if (bodyEl) bodyEl.style.background = xtermTheme.background;
273
+ var frameEl = modalEl.querySelector(".tui-modal");
274
+ if (frameEl) frameEl.style.background = xtermTheme.background;
275
+ }
276
+ }
277
+
278
+ // Live font update for the attention modal. Refit after applying so
279
+ // cell metrics + PTY cols/rows stay in sync.
280
+ onTerminalFontChange(function (family, size) {
281
+ if (!modalXterm) return;
282
+ try {
283
+ if (family) modalXterm.options.fontFamily = family;
284
+ if (size) modalXterm.options.fontSize = size;
285
+ } catch (e) {}
286
+ scheduleModalResize();
287
+ });
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { refreshIcons } from './icons.js';
5
5
  import { showToast } from './utils.js';
6
- import { toggleDarkMode, getCurrentTheme, getChatLayout, setChatLayout } from './theme.js';
6
+ import { getChatLayout, setChatLayout } from './theme.js';
7
+ import { applyTerminalFont, getTerminalFontFamily, getTerminalFontSize } from './terminal-prefs.js';
7
8
  import { showEmailSetupModal, getEmailAccountListCache } from './context-sources.js';
8
9
  import { setSTTLang } from './stt.js';
9
10
  import { userAvatarUrl } from './avatar.js';
@@ -230,21 +231,49 @@ export function initUserSettings(appCtx) {
230
231
  });
231
232
  }
232
233
 
233
- // Theme switcher (Light / Dark)
234
- var themeSwitcher = document.getElementById('us-theme-switcher');
235
- if (themeSwitcher) {
236
- var themeBtns = themeSwitcher.querySelectorAll('.layout-option');
237
- for (var ti = 0; ti < themeBtns.length; ti++) {
238
- themeBtns[ti].addEventListener('click', function () {
239
- var mode = this.dataset.theme;
240
- var current = getCurrentTheme();
241
- var currentMode = (current && current.variant) || 'dark';
242
- if (mode !== currentMode) toggleDarkMode();
243
- for (var tj = 0; tj < themeBtns.length; tj++) {
244
- themeBtns[tj].classList.toggle('selected', themeBtns[tj].dataset.theme === mode);
245
- }
246
- });
247
- }
234
+ // Theme picker lives on the user island button now (palette icon).
235
+ // No appearance-section mount required.
236
+
237
+ // Terminal font: family select + optional custom input + size number.
238
+ // Saves to server on change and immediately applies to every open
239
+ // xterm via applyTerminalFont (terminal-prefs.js fanout).
240
+ var tfSelect = document.getElementById('us-term-font-family');
241
+ var tfCustom = document.getElementById('us-term-font-family-custom');
242
+ var tfSize = document.getElementById('us-term-font-size');
243
+
244
+ function persistTermFont(family, size) {
245
+ fetch('/api/user/terminal-font', {
246
+ method: 'PUT',
247
+ headers: { 'Content-Type': 'application/json' },
248
+ body: JSON.stringify({ family: family, size: size }),
249
+ }).catch(function () {});
250
+ }
251
+
252
+ if (tfSelect && tfCustom && tfSize) {
253
+ tfSelect.addEventListener('change', function () {
254
+ if (tfSelect.value === '__custom__') {
255
+ tfCustom.classList.remove('hidden');
256
+ tfCustom.focus();
257
+ return;
258
+ }
259
+ tfCustom.classList.add('hidden');
260
+ applyTerminalFont(tfSelect.value, undefined);
261
+ persistTermFont(tfSelect.value, undefined);
262
+ });
263
+ tfCustom.addEventListener('change', function () {
264
+ var v = tfCustom.value.trim();
265
+ if (!v) return;
266
+ applyTerminalFont(v, undefined);
267
+ persistTermFont(v, undefined);
268
+ });
269
+ tfSize.addEventListener('change', function () {
270
+ var n = Number(tfSize.value);
271
+ if (!isFinite(n)) return;
272
+ n = Math.max(9, Math.min(32, Math.round(n)));
273
+ tfSize.value = String(n);
274
+ applyTerminalFont(undefined, n);
275
+ persistTermFont(undefined, n);
276
+ });
248
277
  }
249
278
 
250
279
  // Layout switcher (Bubble / Channel)
@@ -460,18 +489,37 @@ function populateAccount() {
460
489
  // Hide account section in single-user mode (no username)
461
490
  var accountNav = settingsEl.querySelector('[data-section="us-account"]');
462
491
  if (accountNav) accountNav.style.display = data.username ? '' : 'none';
492
+ // Terminal font: seed in-memory prefs from server, populate inputs.
493
+ if (data.terminalFont && typeof data.terminalFont === "object") {
494
+ applyTerminalFont(data.terminalFont.family, data.terminalFont.size);
495
+ }
496
+ var tfSelectEl = document.getElementById('us-term-font-family');
497
+ var tfCustomEl = document.getElementById('us-term-font-family-custom');
498
+ var tfSizeEl = document.getElementById('us-term-font-size');
499
+ if (tfSelectEl && tfCustomEl) {
500
+ var fam = getTerminalFontFamily();
501
+ var matched = false;
502
+ for (var oi = 0; oi < tfSelectEl.options.length; oi++) {
503
+ if (tfSelectEl.options[oi].value === fam) {
504
+ tfSelectEl.value = fam;
505
+ matched = true;
506
+ break;
507
+ }
508
+ }
509
+ if (!matched) {
510
+ tfSelectEl.value = '__custom__';
511
+ tfCustomEl.value = fam;
512
+ tfCustomEl.classList.remove('hidden');
513
+ } else {
514
+ tfCustomEl.classList.add('hidden');
515
+ }
516
+ }
517
+ if (tfSizeEl) tfSizeEl.value = String(getTerminalFontSize());
463
518
  // Auto-continue toggle
464
519
  var acToggle = document.getElementById('us-auto-continue');
465
520
  if (acToggle) acToggle.checked = !!data.autoContinueOnRateLimit;
466
- // Theme switcher
467
- var tSwitcher = document.getElementById('us-theme-switcher');
468
- if (tSwitcher) {
469
- var currentMode = (getCurrentTheme() && getCurrentTheme().variant) || 'dark';
470
- var tBtns = tSwitcher.querySelectorAll('.layout-option');
471
- for (var ti = 0; ti < tBtns.length; ti++) {
472
- tBtns[ti].classList.toggle('selected', tBtns[ti].dataset.theme === currentMode);
473
- }
474
- }
521
+ // Theme picker self-syncs active selection via theme.js's applyTheme
522
+ // change callbacks (updatePickerActive). Nothing to do here.
475
523
  // Layout switcher: sync from server response
476
524
  // Sync mate onboarding state from server
477
525
  if (data.mateOnboardingShown) {
@@ -30,6 +30,7 @@ function attachSettings(ctx) {
30
30
  profile.chatLayout = mu.chatLayout || "channel";
31
31
  profile.mateOnboardingShown = !!mu.mateOnboardingShown;
32
32
  profile.matesEnabled = mu.matesEnabled !== false;
33
+ try { profile.terminalFont = users.getTerminalFont(mu.id); } catch (e) {}
33
34
  res.writeHead(200, { "Content-Type": "application/json" });
34
35
  res.end(JSON.stringify(profile));
35
36
  return true;
@@ -52,6 +53,14 @@ function attachSettings(ctx) {
52
53
  profile.chatLayout = dc.chatLayout || "channel";
53
54
  profile.mateOnboardingShown = !!dc.mateOnboardingShown;
54
55
  profile.matesEnabled = dc.matesEnabled !== false;
56
+ if (dc.terminalFont && typeof dc.terminalFont === "object") {
57
+ profile.terminalFont = {
58
+ family: dc.terminalFont.family || "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
59
+ size: typeof dc.terminalFont.size === "number" ? dc.terminalFont.size : 13,
60
+ };
61
+ } else {
62
+ profile.terminalFont = { family: "'SF Mono', Menlo, Monaco, 'Courier New', monospace", size: 13 };
63
+ }
55
64
  }
56
65
  // Check if custom avatar file exists
57
66
  try {
@@ -514,6 +523,52 @@ function attachSettings(ctx) {
514
523
  return true;
515
524
  }
516
525
 
526
+ // PUT /api/user/terminal-font
527
+ // Body: { family?: string, size?: number }
528
+ // Per-user terminal font preferences shared across every xterm in
529
+ // the Clay UI (bottom panel, Claude TUI session view, attention
530
+ // modal). Single-user mode persists into daemon config; multi-user
531
+ // mode persists into users.json.
532
+ if (req.method === "PUT" && fullUrl === "/api/user/terminal-font") {
533
+ var isMultiUserTF = users.isMultiUser();
534
+ var muTF = getMultiUserFromReq(req);
535
+ var bodyTF = "";
536
+ req.on("data", function (chunk) { bodyTF += chunk; });
537
+ req.on("end", function () {
538
+ try {
539
+ var dataTF = JSON.parse(bodyTF || "{}");
540
+ if (isMultiUserTF) {
541
+ if (!muTF) {
542
+ res.writeHead(401, { "Content-Type": "application/json" });
543
+ res.end('{"error":"unauthorized"}');
544
+ return;
545
+ }
546
+ var resultTF = users.setTerminalFont(muTF.id, dataTF.family, dataTF.size);
547
+ if (resultTF.error) {
548
+ res.writeHead(400, { "Content-Type": "application/json" });
549
+ res.end(JSON.stringify({ error: resultTF.error }));
550
+ return;
551
+ }
552
+ res.writeHead(200, { "Content-Type": "application/json" });
553
+ res.end(JSON.stringify({ ok: true, terminalFont: resultTF.terminalFont }));
554
+ } else {
555
+ if (typeof opts.onSetTerminalFont === "function") {
556
+ var saved = opts.onSetTerminalFont(dataTF.family, dataTF.size);
557
+ res.writeHead(200, { "Content-Type": "application/json" });
558
+ res.end(JSON.stringify({ ok: true, terminalFont: saved }));
559
+ } else {
560
+ res.writeHead(500, { "Content-Type": "application/json" });
561
+ res.end('{"error":"single-user font update unsupported"}');
562
+ }
563
+ }
564
+ } catch (e) {
565
+ res.writeHead(400, { "Content-Type": "application/json" });
566
+ res.end('{"error":"Invalid request"}');
567
+ }
568
+ });
569
+ return true;
570
+ }
571
+
517
572
  // POST /api/user/mate-onboarded
518
573
  if (req.method === "POST" && fullUrl === "/api/user/mate-onboarded") {
519
574
  var isMultiUser = users.isMultiUser();
@@ -405,6 +405,49 @@ function attachPreferences(deps) {
405
405
  return { error: "User not found" };
406
406
  }
407
407
 
408
+ // --- Terminal font preferences ---
409
+ //
410
+ // Per-user font family + size for every xterm in Clay (bottom panel
411
+ // shell, Claude TUI session view, TUI attention modal). Stored as a
412
+ // single object so the two values stay together.
413
+
414
+ var DEFAULT_TERM_FONT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace";
415
+ var DEFAULT_TERM_FONT_SIZE = 13;
416
+ var MIN_TERM_FONT_SIZE = 9;
417
+ var MAX_TERM_FONT_SIZE = 32;
418
+
419
+ function getTerminalFont(userId) {
420
+ var data = loadUsers();
421
+ for (var i = 0; i < data.users.length; i++) {
422
+ if (data.users[i].id === userId) {
423
+ var tf = data.users[i].terminalFont || {};
424
+ return {
425
+ family: (typeof tf.family === "string" && tf.family.trim()) ? tf.family : DEFAULT_TERM_FONT_FAMILY,
426
+ size: (typeof tf.size === "number" && tf.size >= MIN_TERM_FONT_SIZE && tf.size <= MAX_TERM_FONT_SIZE) ? tf.size : DEFAULT_TERM_FONT_SIZE,
427
+ };
428
+ }
429
+ }
430
+ return { family: DEFAULT_TERM_FONT_FAMILY, size: DEFAULT_TERM_FONT_SIZE };
431
+ }
432
+
433
+ function setTerminalFont(userId, family, size) {
434
+ var data = loadUsers();
435
+ for (var i = 0; i < data.users.length; i++) {
436
+ if (data.users[i].id === userId) {
437
+ var current = data.users[i].terminalFont || {};
438
+ var nextFamily = (typeof family === "string" && family.trim()) ? family.trim().slice(0, 200) : current.family;
439
+ var nextSize = current.size;
440
+ if (typeof size === "number" && size >= MIN_TERM_FONT_SIZE && size <= MAX_TERM_FONT_SIZE) {
441
+ nextSize = Math.round(size);
442
+ }
443
+ data.users[i].terminalFont = { family: nextFamily, size: nextSize };
444
+ saveUsers(data);
445
+ return { ok: true, terminalFont: data.users[i].terminalFont };
446
+ }
447
+ }
448
+ return { error: "User not found" };
449
+ }
450
+
408
451
  // --- What's New seen ids ---
409
452
  //
410
453
  // Per-user list of dismissed "What's New" entry ids. The whats-new
@@ -477,6 +520,8 @@ function attachPreferences(deps) {
477
520
  setToolPalette: setToolPalette,
478
521
  getWhatsNewSeenIds: getWhatsNewSeenIds,
479
522
  markWhatsNewSeen: markWhatsNewSeen,
523
+ getTerminalFont: getTerminalFont,
524
+ setTerminalFont: setTerminalFont,
480
525
  setMateOnboarded: setMateOnboarded,
481
526
  };
482
527
  }
package/lib/users.js CHANGED
@@ -426,6 +426,8 @@ var getToolPalettes = preferences.getToolPalettes;
426
426
  var setToolPalette = preferences.setToolPalette;
427
427
  var getWhatsNewSeenIds = preferences.getWhatsNewSeenIds;
428
428
  var markWhatsNewSeen = preferences.markWhatsNewSeen;
429
+ var getTerminalFont = preferences.getTerminalFont;
430
+ var setTerminalFont = preferences.setTerminalFont;
429
431
  var setMateOnboarded = preferences.setMateOnboarded;
430
432
 
431
433
  module.exports = {
@@ -493,6 +495,8 @@ module.exports = {
493
495
  setToolPalette: setToolPalette,
494
496
  getWhatsNewSeenIds: getWhatsNewSeenIds,
495
497
  markWhatsNewSeen: markWhatsNewSeen,
498
+ getTerminalFont: getTerminalFont,
499
+ setTerminalFont: setTerminalFont,
496
500
  getDeletedBuiltinKeys: getDeletedBuiltinKeys,
497
501
  addDeletedBuiltinKey: addDeletedBuiltinKey,
498
502
  removeDeletedBuiltinKey: removeDeletedBuiltinKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.39.0-beta.4",
3
+ "version": "2.39.0",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",