clay-server 2.40.0-beta.1 → 2.40.0-beta.2

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.
@@ -24,8 +24,17 @@ var CLAY_HOOK_MARKER = "clay:tui-notify";
24
24
  // Allow-patterns Clay manages in ~/.claude/settings.json `permissions.allow`.
25
25
  // These mirror sdk-bridge.js `checkToolWhitelist` so TUI sessions get the
26
26
  // same auto-approval convenience as SDK sessions. Conservative: only
27
- // commands that stay safe even under Claude Code's prefix matching (compound
28
- // commands like `ls && rm -rf /` would otherwise sneak past `Bash(ls:*)`).
27
+ // commands that stay safe even under Claude Code's wildcard matching
28
+ // (compound commands like `ls && rm -rf /` would otherwise sneak past
29
+ // `Bash(ls *)`).
30
+ //
31
+ // Pattern syntax note: claude 2.x uses space-wildcard form `Bash(cmd *)`
32
+ // for prefix matching. The older colon form `Bash(cmd:*)` is flagged as
33
+ // legacy in the CLI and no longer reliably matches argument-bearing
34
+ // commands - the TUI was still prompting for read-only invocations like
35
+ // `ls -la` even with the allow-list installed. Patterns below use the
36
+ // modern form. The bare command (e.g. `Bash(ls)`) is included alongside
37
+ // the wildcard so zero-arg invocations are also covered.
29
38
  //
30
39
  // User-authored entries are preserved -- on re-install we only strip
31
40
  // patterns that appear in this constant list.
@@ -48,46 +57,48 @@ var CLAY_MANAGED_ALLOW = [
48
57
  // Safe Bash commands. Match the curated set in sdk-bridge.js's
49
58
  // safeBashCommands, restricted to ones whose pure read-only behavior
50
59
  // doesn't depend on argument shape.
51
- "Bash(ls:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)",
52
- "Bash(file:*)", "Bash(stat:*)", "Bash(find:*)", "Bash(tree:*)",
53
- "Bash(du:*)", "Bash(df:*)", "Bash(readlink:*)", "Bash(realpath:*)",
54
- "Bash(basename:*)", "Bash(dirname:*)",
55
- "Bash(grep:*)", "Bash(rg:*)", "Bash(ag:*)", "Bash(ack:*)",
56
- "Bash(fgrep:*)", "Bash(egrep:*)",
57
- "Bash(which:*)", "Bash(type:*)", "Bash(whereis:*)",
58
- "Bash(echo:*)", "Bash(printf:*)", "Bash(env:*)", "Bash(printenv:*)",
59
- "Bash(pwd:*)", "Bash(whoami:*)", "Bash(id:*)", "Bash(groups:*)",
60
- "Bash(date:*)", "Bash(uname:*)", "Bash(hostname:*)", "Bash(uptime:*)",
61
- "Bash(arch:*)", "Bash(nproc:*)", "Bash(free:*)",
62
- "Bash(lsb_release:*)", "Bash(sw_vers:*)", "Bash(locale:*)",
60
+ "Bash(ls)", "Bash(ls *)", "Bash(cat *)", "Bash(head *)", "Bash(tail *)", "Bash(wc *)",
61
+ "Bash(file *)", "Bash(stat *)", "Bash(find *)", "Bash(tree)", "Bash(tree *)",
62
+ "Bash(du *)", "Bash(df)", "Bash(df *)", "Bash(readlink *)", "Bash(realpath *)",
63
+ "Bash(basename *)", "Bash(dirname *)",
64
+ "Bash(grep *)", "Bash(rg *)", "Bash(ag *)", "Bash(ack *)",
65
+ "Bash(fgrep *)", "Bash(egrep *)",
66
+ "Bash(which *)", "Bash(type *)", "Bash(whereis *)",
67
+ "Bash(echo)", "Bash(echo *)", "Bash(printf *)", "Bash(env)", "Bash(env *)", "Bash(printenv)", "Bash(printenv *)",
68
+ "Bash(pwd)", "Bash(whoami)", "Bash(id)", "Bash(id *)", "Bash(groups)", "Bash(groups *)",
69
+ "Bash(date)", "Bash(date *)", "Bash(uname)", "Bash(uname *)", "Bash(hostname)", "Bash(uptime)",
70
+ "Bash(arch)", "Bash(nproc)", "Bash(free)", "Bash(free *)",
71
+ "Bash(lsb_release *)", "Bash(sw_vers)", "Bash(sw_vers *)", "Bash(locale)", "Bash(locale *)",
63
72
  // Git read-only subcommands. Listed individually so write subcommands
64
73
  // (commit, push, reset, etc.) still prompt.
65
- "Bash(git status:*)", "Bash(git log:*)", "Bash(git diff:*)",
66
- "Bash(git show:*)", "Bash(git branch:*)", "Bash(git tag:*)",
67
- "Bash(git remote:*)", "Bash(git config --get:*)",
68
- "Bash(git rev-parse:*)", "Bash(git ls-files:*)",
69
- "Bash(git blame:*)", "Bash(git describe:*)",
74
+ "Bash(git status)", "Bash(git status *)", "Bash(git log)", "Bash(git log *)",
75
+ "Bash(git diff)", "Bash(git diff *)", "Bash(git show)", "Bash(git show *)",
76
+ "Bash(git branch)", "Bash(git branch *)", "Bash(git tag)", "Bash(git tag *)",
77
+ "Bash(git remote)", "Bash(git remote *)", "Bash(git config --get *)",
78
+ "Bash(git rev-parse *)", "Bash(git ls-files)", "Bash(git ls-files *)",
79
+ "Bash(git blame *)", "Bash(git describe)", "Bash(git describe *)",
70
80
  // Package manager read-only subcommands
71
- "Bash(npm list:*)", "Bash(npm ls:*)", "Bash(npm view:*)", "Bash(npm outdated:*)",
72
- "Bash(npm config get:*)", "Bash(yarn list:*)", "Bash(pnpm list:*)",
81
+ "Bash(npm list)", "Bash(npm list *)", "Bash(npm ls)", "Bash(npm ls *)",
82
+ "Bash(npm view *)", "Bash(npm outdated)", "Bash(npm outdated *)",
83
+ "Bash(npm config get *)", "Bash(yarn list)", "Bash(yarn list *)", "Bash(pnpm list)", "Bash(pnpm list *)",
73
84
  // Version checks
74
- "Bash(node --version:*)", "Bash(npm --version:*)", "Bash(python --version:*)",
75
- "Bash(python3 --version:*)", "Bash(go version:*)", "Bash(ruby --version:*)",
85
+ "Bash(node --version)", "Bash(npm --version)", "Bash(python --version)",
86
+ "Bash(python3 --version)", "Bash(go version)", "Bash(ruby --version)",
76
87
  // Text processing (pure stdin/stdout)
77
- "Bash(jq:*)", "Bash(yq:*)", "Bash(sort:*)", "Bash(uniq:*)",
78
- "Bash(cut:*)", "Bash(tr:*)", "Bash(awk:*)", "Bash(sed:*)",
79
- "Bash(paste:*)", "Bash(column:*)", "Bash(rev:*)", "Bash(tac:*)",
80
- "Bash(nl:*)", "Bash(fmt:*)", "Bash(comm:*)", "Bash(join:*)",
88
+ "Bash(jq *)", "Bash(yq *)", "Bash(sort)", "Bash(sort *)", "Bash(uniq)", "Bash(uniq *)",
89
+ "Bash(cut *)", "Bash(tr *)", "Bash(awk *)", "Bash(sed *)",
90
+ "Bash(paste *)", "Bash(column *)", "Bash(rev)", "Bash(rev *)", "Bash(tac)", "Bash(tac *)",
91
+ "Bash(nl)", "Bash(nl *)", "Bash(fmt)", "Bash(fmt *)", "Bash(comm *)", "Bash(join *)",
81
92
  // Comparison / hashing (read-only)
82
- "Bash(diff:*)", "Bash(cmp:*)", "Bash(md5sum:*)", "Bash(sha256sum:*)",
83
- "Bash(sha1sum:*)", "Bash(shasum:*)", "Bash(cksum:*)", "Bash(base64:*)",
84
- "Bash(xxd:*)", "Bash(od:*)", "Bash(hexdump:*)",
93
+ "Bash(diff *)", "Bash(cmp *)", "Bash(md5sum *)", "Bash(sha256sum *)",
94
+ "Bash(sha1sum *)", "Bash(shasum *)", "Bash(cksum *)", "Bash(base64)", "Bash(base64 *)",
95
+ "Bash(xxd *)", "Bash(od *)", "Bash(hexdump *)",
85
96
  // Calendar / math
86
- "Bash(cal:*)", "Bash(bc:*)", "Bash(expr:*)", "Bash(factor:*)", "Bash(seq:*)",
97
+ "Bash(cal)", "Bash(cal *)", "Bash(bc)", "Bash(bc *)", "Bash(expr *)", "Bash(factor *)", "Bash(seq *)",
87
98
  // Process / network introspection (read-only)
88
- "Bash(ps:*)", "Bash(top:*)", "Bash(htop:*)", "Bash(pgrep:*)", "Bash(lsof:*)",
89
- "Bash(netstat:*)", "Bash(ss:*)", "Bash(ifconfig:*)", "Bash(ip:*)",
90
- "Bash(dig:*)", "Bash(nslookup:*)", "Bash(host:*)",
99
+ "Bash(ps)", "Bash(ps *)", "Bash(top)", "Bash(top *)", "Bash(htop)", "Bash(pgrep *)", "Bash(lsof)", "Bash(lsof *)",
100
+ "Bash(netstat)", "Bash(netstat *)", "Bash(ss)", "Bash(ss *)", "Bash(ifconfig)", "Bash(ifconfig *)", "Bash(ip *)",
101
+ "Bash(dig *)", "Bash(nslookup *)", "Bash(host *)",
91
102
  ];
92
103
 
93
104
  function buildHookCommand(notifyUrl) {
@@ -186,11 +197,27 @@ function installNotificationHook(opts) {
186
197
  return { installed: installed, errors: errors };
187
198
  }
188
199
 
200
+ // Pattern shapes Clay used to install in older versions. Listed here so an
201
+ // upgrade can strip them from settings.json before re-installing the modern
202
+ // list. Without this, users who already had Clay running would end up with
203
+ // stale legacy-syntax entries next to the new ones - mostly harmless but
204
+ // noisy, and confusing if anyone reads the file.
205
+ function isLegacyClayPattern(p) {
206
+ if (typeof p !== "string") return false;
207
+ // Old colon-prefix Bash patterns: Bash(ls:*), Bash(git status:*), etc.
208
+ // Modern patterns use space-asterisk and never contain ":*", so this is
209
+ // a safe identifier for old Clay entries even though it can't tell apart
210
+ // user-authored colon patterns. The risk is judged acceptable because
211
+ // claude flags the colon form as legacy and is phasing it out anyway.
212
+ if (p.indexOf("Bash(") === 0 && p.indexOf(":*)") !== -1) return true;
213
+ return false;
214
+ }
215
+
189
216
  // Merge Clay's managed allow-list into permissions.allow without disturbing
190
217
  // the user's own entries. We identify "ours" by membership in
191
- // CLAY_MANAGED_ALLOW: re-install strips any existing CLAY_MANAGED_ALLOW
192
- // patterns, then re-inserts the current list. User-authored patterns
193
- // survive unchanged because they're never in CLAY_MANAGED_ALLOW.
218
+ // CLAY_MANAGED_ALLOW (current) or isLegacyClayPattern (previous shape):
219
+ // re-install strips both, then re-inserts the current list. User-authored
220
+ // patterns survive unchanged.
194
221
  function mergeAllowList(settingsPath, patterns) {
195
222
  var data = readSettings(settingsPath);
196
223
  if (!data.permissions || typeof data.permissions !== "object") data.permissions = {};
@@ -199,8 +226,13 @@ function mergeAllowList(settingsPath, patterns) {
199
226
  var managedSet = {};
200
227
  for (var i = 0; i < CLAY_MANAGED_ALLOW.length; i++) managedSet[CLAY_MANAGED_ALLOW[i]] = true;
201
228
 
202
- // Strip prior Clay-managed entries, then append the fresh list.
203
- var preserved = allow.filter(function (p) { return !managedSet[p]; });
229
+ // Strip prior Clay-managed entries (current shape + legacy colon shape),
230
+ // then append the fresh list.
231
+ var preserved = allow.filter(function (p) {
232
+ if (managedSet[p]) return false;
233
+ if (isLegacyClayPattern(p)) return false;
234
+ return true;
235
+ });
204
236
  var next = preserved.concat(patterns);
205
237
 
206
238
  var before = JSON.stringify(allow);
@@ -544,6 +544,27 @@
544
544
  .config-radio-item:hover { background: rgba(var(--overlay-rgb),0.05); color: var(--text); }
545
545
  .config-radio-item.active { color: var(--accent); font-weight: 600; }
546
546
 
547
+ /* Locked picker items (Codex GUI sessions after the first message: model
548
+ is bound to the thread, picks would be silently ignored). Active model
549
+ stays slightly more legible so the user can still see which one's live. */
550
+ .config-radio-item.locked {
551
+ cursor: not-allowed;
552
+ opacity: 0.5;
553
+ }
554
+ .config-radio-item.locked:hover {
555
+ background: transparent;
556
+ color: var(--text-secondary);
557
+ }
558
+ .config-radio-item.locked.active {
559
+ opacity: 0.85;
560
+ }
561
+ .config-model-hint {
562
+ padding: 4px 14px 10px;
563
+ font-size: 11px;
564
+ color: var(--text-dimmer);
565
+ font-style: italic;
566
+ }
567
+
547
568
  /* Segmented control for effort */
548
569
  .config-segmented {
549
570
  display: flex;
@@ -358,6 +358,42 @@
358
358
  background: rgba(var(--overlay-rgb), 0.06);
359
359
  }
360
360
 
361
+ /* Vendor-split row: Claude + Codex side-by-side. Used inside
362
+ renderMobileSessionsInto so users can pick the vendor on mobile, the
363
+ same way the desktop sidebar already does. */
364
+ .mobile-session-new-row {
365
+ display: flex;
366
+ gap: 6px;
367
+ padding: 6px 8px 8px;
368
+ border-bottom: 1px solid var(--border-subtle);
369
+ }
370
+
371
+ .mobile-session-new-row .mobile-session-new-vendor {
372
+ flex: 1 1 0;
373
+ width: auto;
374
+ margin-bottom: 0;
375
+ padding: 10px 12px;
376
+ border: 1px solid var(--border-subtle);
377
+ border-radius: 8px;
378
+ background: rgba(var(--overlay-rgb), 0.03);
379
+ color: var(--text);
380
+ justify-content: center;
381
+ gap: 8px;
382
+ }
383
+ .mobile-session-new-row .mobile-session-new-vendor:last-of-type {
384
+ border-bottom: 1px solid var(--border-subtle);
385
+ }
386
+ .mobile-session-new-row .mobile-session-new-vendor:active {
387
+ background: rgba(var(--overlay-rgb), 0.1);
388
+ }
389
+ .mobile-session-new-icon {
390
+ width: 18px;
391
+ height: 18px;
392
+ flex-shrink: 0;
393
+ border-radius: 4px;
394
+ object-fit: cover;
395
+ }
396
+
361
397
  /* --- Chat filter bar (horizontal scroll chips) --- */
362
398
  .mobile-chat-filter-bar {
363
399
  display: flex;
@@ -862,8 +862,32 @@
862
862
 
863
863
  /* Header TUI font icon button - single trigger that opens a popover
864
864
  with the full font picker + size stepper. Visible only when a TUI
865
- session is active. Inherits title-bar icon button styling so it
866
- blends with the existing toolbar. */
865
+ session is active. Matches the title-bar icon button tone used by
866
+ #terminal-toggle-btn / #find-in-session-btn / #debate-pdf-btn so
867
+ it blends into the toolbar instead of looking like a raw button. */
868
+ .header-tui-font-btn {
869
+ display: flex;
870
+ align-items: center;
871
+ justify-content: center;
872
+ background: none;
873
+ border: 1px solid transparent;
874
+ border-radius: 8px;
875
+ color: var(--text-dimmer);
876
+ cursor: pointer;
877
+ padding: 4px;
878
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
879
+ }
880
+ .header-tui-font-btn .lucide { width: 15px; height: 15px; }
881
+ .header-tui-font-btn:hover {
882
+ color: var(--text-secondary);
883
+ background: rgba(var(--overlay-rgb), 0.04);
884
+ border-color: var(--border);
885
+ }
886
+ .header-tui-font-btn.active {
887
+ color: var(--text);
888
+ background: rgba(var(--overlay-rgb), 0.06);
889
+ border-color: var(--border);
890
+ }
867
891
  .header-tui-font-btn.hidden { display: none; }
868
892
 
869
893
  /* Floating popover that holds the font menu + size row. Anchored
@@ -168,10 +168,17 @@ export function processMessage(msg) {
168
168
  if (!store.get('sessionIsProcessing')) {
169
169
  applyDeadSessionTodoCompaction();
170
170
  }
171
- // Hide vendor toggle if session has history (vendor already locked)
171
+ // Show the locked vendor toggle only when history exists AND the
172
+ // vendor isn't already committed. With a committed vendor,
173
+ // session_switched has already hidden the toggle and shown the
174
+ // small #active-vendor-indicator; re-showing the locked toggle
175
+ // here would duplicate the avatar next to the indicator.
172
176
  var _hTotal = store.get('historyTotal') || 0;
173
177
  var _vtw2 = document.getElementById("vendor-toggle-wrap");
174
- if (_vtw2 && _hTotal > 0) { _vtw2.classList.remove("hidden"); _vtw2.classList.add("locked"); }
178
+ if (_vtw2 && _hTotal > 0 && !store.get('currentVendor')) {
179
+ _vtw2.classList.remove("hidden");
180
+ _vtw2.classList.add("locked");
181
+ }
175
182
  // Restore cached rich context usage BEFORE updateContextPanel runs
176
183
  if (msg.contextUsage) {
177
184
  store.set({ richContextUsage: msg.contextUsage });
@@ -177,16 +177,27 @@ function hasBeta(name) {
177
177
 
178
178
  function rebuildModelList() {
179
179
  if (!configModelList) return;
180
- // GUI chat sessions have their model bound at creation time (picked from
181
- // the new-session config before the first message). Showing a picker
182
- // inside the chat is misleading: changing it mid-thread breaks tool
183
- // schemas and cache reuse. Hide the entire MODEL section in GUI mode.
180
+ // Picker visibility by vendor+mode:
181
+ // Claude TUI -> shown (Claude TUI accepts mid-thread model swaps).
182
+ // Claude GUI -> hidden (Agent SDK binds model at session creation;
183
+ // changing mid-thread breaks tool schemas and cache reuse).
184
+ // Codex GUI -> shown but locked after the first message (Codex protocol
185
+ // binds model at thread creation; sdk-bridge setModel
186
+ // already stores into sm.currentModel when there's no
187
+ // active queryInstance, so picks made before the first
188
+ // message do take effect on thread start).
184
189
  var modelSection = configModelList.parentElement;
185
190
  var s = store.snap();
186
- var hideModelPicker = s.activeSessionMode === "gui";
191
+ var vendor = s.currentVendor || "claude";
192
+ var hideModelPicker = s.activeSessionMode === "gui" && vendor === "claude";
187
193
  if (modelSection) modelSection.style.display = hideModelPicker ? "none" : "";
188
194
  configModelList.innerHTML = "";
189
195
  if (hideModelPicker) return;
196
+
197
+ var lockedForCodex = vendor === "codex"
198
+ && s.activeSessionMode === "gui"
199
+ && !!s.sessionHasHistory;
200
+
190
201
  var list = s.currentModels.length > 0 ? s.currentModels : (s.currentModel ? [{ value: s.currentModel, displayName: s.currentModel }] : []);
191
202
  for (var i = 0; i < list.length; i++) {
192
203
  var item = list[i];
@@ -196,19 +207,32 @@ function rebuildModelList() {
196
207
  var btn = document.createElement("button");
197
208
  btn.className = "config-radio-item";
198
209
  if (value === s.currentModel) btn.classList.add("active");
210
+ if (lockedForCodex) btn.classList.add("locked");
199
211
  btn.dataset.model = value;
200
212
  btn.textContent = label;
201
- btn.addEventListener("click", function () {
202
- var model = this.dataset.model;
203
- var ws = getWs();
204
- if (ws && ws.readyState === 1) {
205
- ws.send(JSON.stringify({ type: "set_model", model: model }));
206
- }
207
- configPopover.classList.add("hidden");
208
- configChip.classList.remove("active");
209
- });
213
+ if (lockedForCodex) {
214
+ btn.disabled = true;
215
+ btn.title = "Model is locked after the first message in a Codex session. Start a new session to change it.";
216
+ } else {
217
+ btn.addEventListener("click", function () {
218
+ var model = this.dataset.model;
219
+ var ws = getWs();
220
+ if (ws && ws.readyState === 1) {
221
+ ws.send(JSON.stringify({ type: "set_model", model: model }));
222
+ }
223
+ configPopover.classList.add("hidden");
224
+ configChip.classList.remove("active");
225
+ });
226
+ }
210
227
  configModelList.appendChild(btn);
211
228
  }
229
+
230
+ if (lockedForCodex) {
231
+ var hint = document.createElement("div");
232
+ hint.className = "config-model-hint";
233
+ hint.textContent = "Locked after first message — start a new session to change.";
234
+ configModelList.appendChild(hint);
235
+ }
212
236
  }
213
237
 
214
238
  function rebuildModeList() {
@@ -260,10 +260,36 @@ export function sendMessage() {
260
260
  if (_selVendor) payload.vendor = _selVendor;
261
261
  ctx.ws.send(JSON.stringify(payload));
262
262
 
263
- // Hide vendor toggle after first message (vendor is locked to this session)
263
+ // First message commits the vendor and bumps the session into
264
+ // has-history state. The server won't re-fire session_switched after a
265
+ // message, so mirror the vendor-toggle/active-indicator swap locally:
266
+ // hide the picker, show the small avatar next to the config chip.
267
+ // Bumping sessionHasHistory drives in-session lock states (e.g. the
268
+ // Codex model picker that becomes informational once the thread is
269
+ // bound to a model).
270
+ var _committedVendor = store.get('currentVendor');
264
271
  var _vtw2 = document.getElementById("vendor-toggle-wrap");
265
- if (_vtw2) { _vtw2.classList.remove("hidden"); _vtw2.classList.add("locked"); }
266
- store.set({ vendorSelectionLocked: false });
272
+ var _avi = document.getElementById("active-vendor-indicator");
273
+ var _avIcon = document.getElementById("active-vendor-icon");
274
+ if (_committedVendor) {
275
+ if (_vtw2) {
276
+ _vtw2.classList.add("hidden");
277
+ _vtw2.classList.remove("locked");
278
+ }
279
+ if (_avi && _avIcon) {
280
+ _avIcon.src = _committedVendor === "codex" ? "/codex-avatar.png" : "/claude-code-avatar.png";
281
+ _avIcon.alt = _committedVendor === "codex" ? "Codex" : "Claude";
282
+ _avi.title = (_committedVendor === "codex" ? "Codex" : "Claude") + " session";
283
+ _avi.classList.remove("hidden");
284
+ }
285
+ } else if (_vtw2) {
286
+ // No committed vendor (defensive — shouldn't happen because the
287
+ // input is otherwise gated on a vendor pick). Fall back to the
288
+ // locked toggle so the running vendor is still visible.
289
+ _vtw2.classList.remove("hidden");
290
+ _vtw2.classList.add("locked");
291
+ }
292
+ store.set({ vendorSelectionLocked: false, sessionHasHistory: true });
267
293
 
268
294
  // Show pre-thinking dots before server responds
269
295
  if (ctx.isMateDm && ctx.isMateDm()) {
@@ -6,6 +6,7 @@ import { iconHtml, refreshIcons } from './icons.js';
6
6
  import { setSTTLang } from './stt.js';
7
7
  import { avatarUrl, mateAvatarUrl, AVATAR_STYLES } from './avatar.js';
8
8
  import { store } from './store.js';
9
+ import { applyTerminalFont } from './terminal-prefs.js';
9
10
 
10
11
  var ctx;
11
12
  var profile = { name: '', lang: 'en-US', avatarStyle: 'thumbs', avatarSeed: '', avatarColor: '#7c3aed', avatarCustom: '' };
@@ -579,6 +580,14 @@ export function initProfile(_ctx) {
579
580
  setSTTLang(profile.lang);
580
581
  }
581
582
 
583
+ // Hydrate terminal font prefs at boot. Previously this only happened
584
+ // when the user opened the Settings modal (user-settings.js's
585
+ // populateAccount), so a hard refresh would drop persisted picks back
586
+ // to defaults until the modal was visited.
587
+ if (data.terminalFont && typeof data.terminalFont === "object") {
588
+ applyTerminalFont(data.terminalFont.family, data.terminalFont.size);
589
+ }
590
+
582
591
  // Apply Mates UI gate at boot so the sidebar avatars / DM picker
583
592
  // entry / home-hub strip are hidden before the user opens settings.
584
593
  // Default true: only flip the body class off when explicitly false.
@@ -304,7 +304,10 @@ function fitNow() {
304
304
  if (!xterm || !fitAddon || !hostEl) return;
305
305
  syncHostBounds();
306
306
  try {
307
+ var prevCols = xterm.cols;
308
+ var prevRows = xterm.rows;
307
309
  fitAddon.fit();
310
+ var dimsChanged = (xterm.cols !== prevCols || xterm.rows !== prevRows);
308
311
  // Inform the server so its PTY's idea of cols/rows matches what xterm
309
312
  // just rendered. Without this, claude TUI redraws using stale dims.
310
313
  if (currentTermId != null && getWs() && getWs().readyState === 1) {
@@ -315,6 +318,14 @@ function fitNow() {
315
318
  rows: xterm.rows,
316
319
  }));
317
320
  }
321
+ // After cols/rows change, the previous frame in xterm's buffer is now
322
+ // reflowed/truncated against the new geometry. Claude TUI (ink) repaints
323
+ // using absolute cursor positioning and only writes cells it considers
324
+ // dirty - cells outside the old bounding box stay showing stale or empty
325
+ // content (the "right half is blank after window enlarges" symptom).
326
+ // Wipe the screen here so the next SIGWINCH-driven redraw lands on a
327
+ // clean canvas.
328
+ if (dimsChanged) xterm.write("\x1b[2J\x1b[H");
318
329
  } catch (e) {}
319
330
  }
320
331
 
@@ -698,16 +698,35 @@ function renderMateMobileActions(container) {
698
698
 
699
699
  // Helper: render sorted sessions into a container with date groups (with loop session grouping)
700
700
  function renderMobileSessionsInto(container) {
701
- var newBtn = document.createElement("button");
702
- newBtn.className = "mobile-session-new";
703
- newBtn.innerHTML = '<i data-lucide="plus" style="width:16px;height:16px"></i> New session';
704
- newBtn.addEventListener("click", function () {
701
+ // Vendor-aware new-session row. Mirrors the desktop sidebar's two-button
702
+ // pattern (Claude defaults to TUI, Codex always GUI) so mobile users can
703
+ // pick the vendor instead of being silently routed to Claude TUI.
704
+ var newRow = document.createElement("div");
705
+ newRow.className = "mobile-session-new-row";
706
+
707
+ var claudeBtn = document.createElement("button");
708
+ claudeBtn.className = "mobile-session-new mobile-session-new-vendor";
709
+ claudeBtn.innerHTML = '<img src="/claude-code-avatar.png" class="mobile-session-new-icon" alt=""><span>Claude</span>';
710
+ claudeBtn.addEventListener("click", function () {
705
711
  if (getWs() && store.get('connected')) {
706
- getWs().send(JSON.stringify({ type: "new_session" }));
712
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: "tui" }));
707
713
  }
708
714
  closeMobileSheet();
709
715
  });
710
- container.appendChild(newBtn);
716
+ newRow.appendChild(claudeBtn);
717
+
718
+ var codexBtn = document.createElement("button");
719
+ codexBtn.className = "mobile-session-new mobile-session-new-vendor";
720
+ codexBtn.innerHTML = '<img src="/codex-avatar.png" class="mobile-session-new-icon" alt=""><span>Codex</span>';
721
+ codexBtn.addEventListener("click", function () {
722
+ if (getWs() && store.get('connected')) {
723
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "codex" }));
724
+ }
725
+ closeMobileSheet();
726
+ });
727
+ newRow.appendChild(codexBtn);
728
+
729
+ container.appendChild(newRow);
711
730
 
712
731
  // Partition: loop sessions vs normal sessions (same logic as desktop renderSessionList)
713
732
  var sessions = getCachedSessions();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.40.0-beta.1",
3
+ "version": "2.40.0-beta.2",
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",