clay-server 2.39.0 → 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);
package/lib/public/app.js CHANGED
@@ -65,6 +65,7 @@ import { initLoopWizard, openRalphWizard as _loopOpenRalphWizard, closeRalphWiza
65
65
  import { initAppNotifications, handleNotificationsState as _notifHandleState, handleNotificationCreated as _notifHandleCreated, handleNotificationDismissed as _notifHandleDismissed, handleNotificationDismissedAll as _notifHandleDismissedAll } from './modules/app-notifications.js';
66
66
  import { initWhatsNew, handleWhatsNewState as _wnHandleState, handleWhatsNewSeenResult as _wnHandleSeenResult } from './modules/whats-new.js';
67
67
  import { initWhatsNewArticle, openArticle as openWhatsNewArticle } from './modules/whats-new-article.js';
68
+ import { initHeaderTuiFont } from './modules/header-tui-font.js';
68
69
  import { createStore, store } from './modules/store.js';
69
70
  import { initPanels, updateConfigChip as _panUpdateConfigChip, getModelEffortLevels as _panGetModelEffortLevels, accumulateUsage as _panAccumulateUsage, updateUsagePanel as _panUpdateUsagePanel, resetUsage as _panResetUsage, toggleUsagePanel as _panToggleUsagePanel, formatTokens as _panFormatTokens, updateStatusPanel as _panUpdateStatusPanel, requestProcessStats as _panRequestProcessStats, toggleStatusPanel as _panToggleStatusPanel, accumulateContext as _panAccumulateContext, updateContextPanel as _panUpdateContextPanel, resetContext as _panResetContext, resetContextData as _panResetContextData, minimizeContext as _panMinimizeContext, expandContext as _panExpandContext, toggleContextPanel as _panToggleContextPanel, getContextView as _panGetContextView, renderCtxPopover as _panRenderCtxPopover, hideCtxPopover as _panHideCtxPopover, formatBytes as _panFormatBytes, formatUptime as _panFormatUptime, getModelSupportsEffort as _panGetModelSupportsEffort, getSessionUsage, setSessionUsage, getContextData, setContextData, setContextView as _panSetContextView, applyContextView as _panApplyContextView } from './modules/app-panels.js';
70
71
  import { initProjects, updateProjectList as _projUpdateProjectList, renderProjectList as _projRenderProjectList, renderTopbarPresence as _projRenderTopbarPresence, switchProject as _projSwitchProject, resetClientState as _projResetClientState, confirmRemoveProject as _projConfirmRemoveProject, handleRemoveProjectCheckResult as _projHandleRemoveProjectCheckResult, handleRemoveProjectResult as _projHandleRemoveProjectResult, openAddProjectModal as _projOpenAddProjectModal, closeAddProjectModal as _projCloseAddProjectModal, handleBrowseDirResult as _projHandleBrowseDirResult, handleAddProjectResult as _projHandleAddProjectResult, handleCloneProgress as _projHandleCloneProgress, showUpdateAvailable as _projShowUpdateAvailable, getCachedProjects, setCachedProjects, getCachedProjectCount, getCachedRemovedProjects, setCachedRemovedProjects } from './modules/app-projects.js';
@@ -604,6 +605,9 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
604
605
  // --- Panels module ---
605
606
  initPanels();
606
607
 
608
+ // --- Header TUI font controls (visible only when TUI session active) ---
609
+ initHeaderTuiFont();
610
+
607
611
  // --- Rendering module ---
608
612
  initRendering();
609
613
 
@@ -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;
@@ -860,45 +860,157 @@
860
860
  }
861
861
  }
862
862
 
863
- /* Terminal font picker row */
864
- .us-term-font-row {
863
+ /* Header TUI font icon button - single trigger that opens a popover
864
+ with the full font picker + size stepper. Visible only when a TUI
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 {
865
869
  display: flex;
866
870
  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);
871
+ justify-content: center;
872
+ background: none;
873
+ border: 1px solid transparent;
875
874
  border-radius: 8px;
876
- background: var(--input-bg);
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 {
877
887
  color: var(--text);
878
- font-size: 13px;
888
+ background: rgba(var(--overlay-rgb), 0.06);
889
+ border-color: var(--border);
879
890
  }
880
- .us-term-font-custom {
881
- flex: 1 1 100%;
882
- padding: 7px 10px;
891
+ .header-tui-font-btn.hidden { display: none; }
892
+
893
+ /* Floating popover that holds the font menu + size row. Anchored
894
+ under the title-bar button via JS positioning. */
895
+ .header-tui-font-popover {
896
+ position: fixed;
897
+ min-width: 220px;
898
+ max-height: 70vh;
899
+ overflow-y: auto;
900
+ padding: 8px;
901
+ background: var(--bg-alt);
883
902
  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;
903
+ border-radius: 10px;
904
+ box-shadow: 0 8px 28px rgba(var(--shadow-rgb), 0.45);
905
+ z-index: 200;
906
+ opacity: 0;
907
+ transform: translateY(-4px);
908
+ pointer-events: none;
909
+ transition: opacity 0.12s ease, transform 0.12s ease;
910
+ }
911
+ .header-tui-font-popover.visible {
912
+ opacity: 1;
913
+ transform: translateY(0);
914
+ pointer-events: auto;
915
+ }
916
+ .header-tui-font-popover-label {
917
+ padding: 4px 8px 6px;
918
+ font-size: 11px;
919
+ font-weight: 600;
920
+ color: var(--text-dimmer);
921
+ text-transform: uppercase;
922
+ letter-spacing: 0.04em;
889
923
  }
890
- .us-term-font-custom.hidden { display: none; }
891
- .us-term-font-size {
892
- width: 64px;
924
+ .header-tui-font-popover-list {
925
+ display: flex;
926
+ flex-direction: column;
927
+ gap: 1px;
928
+ }
929
+ .header-tui-font-popover-item {
930
+ display: flex;
931
+ align-items: center;
932
+ gap: 8px;
933
+ width: 100%;
893
934
  padding: 7px 10px;
894
- border: 1px solid var(--border);
895
- border-radius: 8px;
896
- background: var(--input-bg);
935
+ border: none;
936
+ background: transparent;
897
937
  color: var(--text);
898
938
  font-size: 13px;
899
- text-align: center;
939
+ text-align: left;
940
+ cursor: pointer;
941
+ border-radius: 6px;
942
+ }
943
+ .header-tui-font-popover-item:hover {
944
+ background: rgba(var(--overlay-rgb), 0.06);
945
+ }
946
+ .header-tui-font-popover-check {
947
+ display: inline-flex;
948
+ width: 14px;
949
+ height: 14px;
950
+ align-items: center;
951
+ justify-content: center;
952
+ color: var(--accent);
953
+ opacity: 0;
954
+ flex-shrink: 0;
955
+ }
956
+ .header-tui-font-popover-item.active .header-tui-font-popover-check { opacity: 1; }
957
+ .header-tui-font-popover-item-label {
958
+ flex: 1 1 auto;
959
+ overflow: hidden;
960
+ text-overflow: ellipsis;
961
+ white-space: nowrap;
962
+ }
963
+ .header-tui-font-popover-divider {
964
+ height: 1px;
965
+ background: var(--border-subtle);
966
+ margin: 6px 4px;
967
+ }
968
+ .header-tui-font-popover-size {
969
+ display: flex;
970
+ align-items: center;
971
+ justify-content: space-between;
972
+ gap: 12px;
973
+ padding: 4px 10px 4px;
900
974
  }
901
- .us-term-font-unit {
975
+ .header-tui-font-popover-size-label {
902
976
  font-size: 13px;
903
- color: var(--text-dimmer);
977
+ color: var(--text-secondary);
978
+ }
979
+ .header-tui-font-popover-size-stepper {
980
+ display: inline-flex;
981
+ align-items: center;
982
+ height: 26px;
983
+ border: 1px solid var(--border-subtle);
984
+ border-radius: 6px;
985
+ overflow: hidden;
986
+ }
987
+ .header-tui-font-popover-size-stepper button {
988
+ width: 26px;
989
+ height: 24px;
990
+ border: none;
991
+ background: transparent;
992
+ color: var(--text-secondary);
993
+ font-size: 14px;
994
+ font-weight: 600;
995
+ cursor: pointer;
996
+ display: inline-flex;
997
+ align-items: center;
998
+ justify-content: center;
999
+ padding: 0;
1000
+ }
1001
+ .header-tui-font-popover-size-stepper button:hover {
1002
+ background: rgba(var(--overlay-rgb), 0.06);
1003
+ color: var(--text);
1004
+ }
1005
+ .header-tui-font-popover-size-stepper button:disabled {
1006
+ opacity: 0.35;
1007
+ cursor: default;
1008
+ }
1009
+ .header-tui-font-popover-size-val {
1010
+ min-width: 28px;
1011
+ text-align: center;
1012
+ font-size: 12px;
1013
+ color: var(--text);
1014
+ font-variant-numeric: tabular-nums;
1015
+ user-select: none;
904
1016
  }
@@ -18,6 +18,10 @@
18
18
  <link rel="preconnect" href="https://fonts.googleapis.com">
19
19
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
20
20
  <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;700&family=Nunito:wght@700;800&display=swap" rel="stylesheet">
21
+ <!-- Monospace web fonts used by the TUI font picker in the title bar.
22
+ SF Mono is Apple-system-only (no Google Fonts entry) and
23
+ ui-monospace is a system keyword - both rely on the user's OS. -->
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">
21
25
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css">
22
26
  <script>
23
27
  (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){}})();
@@ -367,6 +371,9 @@
367
371
  <div id="ralph-sticky" class="hidden"></div>
368
372
  <div id="debate-sticky" class="hidden"></div>
369
373
  <div class="status">
374
+ <button type="button" id="header-tui-font-btn" class="header-tui-font-btn hidden" title="Terminal font" aria-label="Terminal font">
375
+ <i data-lucide="type"></i>
376
+ </button>
370
377
  <button id="debate-pdf-btn" class="hidden" title="Export debate as PDF"><i data-lucide="download"></i></button>
371
378
  <button id="find-in-session-btn" title="Search in session (Ctrl+F)"><i data-lucide="search"></i></button>
372
379
  <button id="terminal-toggle-btn" title="Terminal"><i data-lucide="square-terminal"></i><span id="terminal-count" class="hidden"></span></button>
@@ -1001,25 +1008,6 @@
1001
1008
  <!-- Appearance section -->
1002
1009
  <div class="us-section" data-section="us-appearance">
1003
1010
  <h2>Appearance</h2>
1004
- <div class="settings-card">
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>
1021
- </div>
1022
- </div>
1023
1011
  <div class="settings-card">
1024
1012
  <div class="settings-label" style="margin-bottom:10px;">Chat layout</div>
1025
1013
  <div class="layout-switcher" id="us-layout-switcher">
@@ -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 });
@@ -108,6 +108,7 @@ var CODEX_WEBSEARCH_OPTIONS = [
108
108
  var KNOWN_CONTEXT_WINDOWS = {
109
109
  "opus-4-6": 1000000,
110
110
  "claude-sonnet-4": 1000000,
111
+ "gpt-5.5": 1048576,
111
112
  "gpt-5.4": 1048576,
112
113
  "gpt-5.3": 1048576,
113
114
  "gpt-5.2": 1048576,
@@ -176,16 +177,27 @@ function hasBeta(name) {
176
177
 
177
178
  function rebuildModelList() {
178
179
  if (!configModelList) return;
179
- // GUI chat sessions have their model bound at creation time (picked from
180
- // the new-session config before the first message). Showing a picker
181
- // inside the chat is misleading: changing it mid-thread breaks tool
182
- // 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).
183
189
  var modelSection = configModelList.parentElement;
184
190
  var s = store.snap();
185
- var hideModelPicker = s.activeSessionMode === "gui";
191
+ var vendor = s.currentVendor || "claude";
192
+ var hideModelPicker = s.activeSessionMode === "gui" && vendor === "claude";
186
193
  if (modelSection) modelSection.style.display = hideModelPicker ? "none" : "";
187
194
  configModelList.innerHTML = "";
188
195
  if (hideModelPicker) return;
196
+
197
+ var lockedForCodex = vendor === "codex"
198
+ && s.activeSessionMode === "gui"
199
+ && !!s.sessionHasHistory;
200
+
189
201
  var list = s.currentModels.length > 0 ? s.currentModels : (s.currentModel ? [{ value: s.currentModel, displayName: s.currentModel }] : []);
190
202
  for (var i = 0; i < list.length; i++) {
191
203
  var item = list[i];
@@ -195,19 +207,32 @@ function rebuildModelList() {
195
207
  var btn = document.createElement("button");
196
208
  btn.className = "config-radio-item";
197
209
  if (value === s.currentModel) btn.classList.add("active");
210
+ if (lockedForCodex) btn.classList.add("locked");
198
211
  btn.dataset.model = value;
199
212
  btn.textContent = label;
200
- btn.addEventListener("click", function () {
201
- var model = this.dataset.model;
202
- var ws = getWs();
203
- if (ws && ws.readyState === 1) {
204
- ws.send(JSON.stringify({ type: "set_model", model: model }));
205
- }
206
- configPopover.classList.add("hidden");
207
- configChip.classList.remove("active");
208
- });
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
+ }
209
227
  configModelList.appendChild(btn);
210
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
+ }
211
236
  }
212
237
 
213
238
  function rebuildModeList() {
@@ -0,0 +1,233 @@
1
+ // header-tui-font.js
2
+ //
3
+ // Single icon button in the title bar (visible only while a TUI session
4
+ // is active) that opens a popover with terminal font settings: family
5
+ // picker (with per-item font preview) + size stepper. Values live in
6
+ // terminal-prefs.js and persist server-side via
7
+ // PUT /api/user/terminal-font.
8
+
9
+ import {
10
+ applyTerminalFont,
11
+ getTerminalFontFamily,
12
+ getTerminalFontSize,
13
+ onTerminalFontChange,
14
+ } from './terminal-prefs.js';
15
+
16
+ var FONT_OPTIONS = [
17
+ { label: "SF Mono", family: "'SF Mono', Menlo, Monaco, 'Courier New', monospace" },
18
+ { label: "JetBrains Mono", family: "'JetBrains Mono', 'SF Mono', Menlo, monospace" },
19
+ { label: "Fira Code", family: "'Fira Code', 'SF Mono', Menlo, monospace" },
20
+ { label: "Cascadia Code", family: "'Cascadia Code', 'SF Mono', Menlo, monospace" },
21
+ { label: "IBM Plex Mono", family: "'IBM Plex Mono', 'SF Mono', Menlo, monospace" },
22
+ { label: "Source Code Pro", family: "'Source Code Pro', 'SF Mono', Menlo, monospace" },
23
+ { label: "Roboto Mono", family: "'Roboto Mono', 'SF Mono', Menlo, monospace" },
24
+ { label: "System mono", family: "ui-monospace, monospace" },
25
+ ];
26
+
27
+ var MIN_SIZE = 9;
28
+ var MAX_SIZE = 32;
29
+
30
+ var btnEl = null;
31
+ var popoverEl = null;
32
+ var sizeValEl = null;
33
+ var sizeDecEl = null;
34
+ var sizeIncEl = null;
35
+ var menuItemEls = [];
36
+ var popoverOpen = false;
37
+ var outsideHandler = null;
38
+ var keyHandler = null;
39
+ var initialized = false;
40
+
41
+ function escapeHtml(s) {
42
+ return String(s)
43
+ .replace(/&/g, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ .replace(/"/g, '&quot;');
47
+ }
48
+
49
+ function persistTermFont(family, size) {
50
+ fetch('/api/user/terminal-font', {
51
+ method: 'PUT',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ family: family, size: size }),
54
+ }).catch(function () {});
55
+ }
56
+
57
+ function syncSize() {
58
+ if (!sizeValEl) return;
59
+ var sz = getTerminalFontSize();
60
+ sizeValEl.textContent = String(sz);
61
+ if (sizeDecEl) sizeDecEl.disabled = sz <= MIN_SIZE;
62
+ if (sizeIncEl) sizeIncEl.disabled = sz >= MAX_SIZE;
63
+ }
64
+
65
+ function syncActiveFamily() {
66
+ var fam = getTerminalFontFamily();
67
+ for (var i = 0; i < menuItemEls.length; i++) {
68
+ var el = menuItemEls[i];
69
+ el.classList.toggle('active', el.dataset.family === fam);
70
+ }
71
+ }
72
+
73
+ function buildPopover() {
74
+ if (popoverEl) return popoverEl;
75
+ popoverEl = document.createElement('div');
76
+ popoverEl.className = 'header-tui-font-popover';
77
+
78
+ var label = document.createElement('div');
79
+ label.className = 'header-tui-font-popover-label';
80
+ label.textContent = 'Font';
81
+ popoverEl.appendChild(label);
82
+
83
+ var list = document.createElement('div');
84
+ list.className = 'header-tui-font-popover-list';
85
+ for (var i = 0; i < FONT_OPTIONS.length; i++) {
86
+ var opt = FONT_OPTIONS[i];
87
+ var item = document.createElement('button');
88
+ item.type = 'button';
89
+ item.className = 'header-tui-font-popover-item';
90
+ item.dataset.family = opt.family;
91
+ item.style.fontFamily = opt.family;
92
+ item.innerHTML =
93
+ '<span class="header-tui-font-popover-check"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span>' +
94
+ '<span class="header-tui-font-popover-item-label">' + escapeHtml(opt.label) + '</span>';
95
+ (function (family) {
96
+ item.addEventListener('click', function (e) {
97
+ e.stopPropagation();
98
+ applyTerminalFont(family, undefined);
99
+ persistTermFont(family, undefined);
100
+ });
101
+ })(opt.family);
102
+ list.appendChild(item);
103
+ menuItemEls.push(item);
104
+ }
105
+ popoverEl.appendChild(list);
106
+
107
+ var divider = document.createElement('div');
108
+ divider.className = 'header-tui-font-popover-divider';
109
+ popoverEl.appendChild(divider);
110
+
111
+ var sizeRow = document.createElement('div');
112
+ sizeRow.className = 'header-tui-font-popover-size';
113
+ sizeRow.innerHTML =
114
+ '<span class="header-tui-font-popover-size-label">Size</span>' +
115
+ '<div class="header-tui-font-popover-size-stepper">' +
116
+ '<button type="button" data-step="-1" title="Smaller">−</button>' +
117
+ '<span class="header-tui-font-popover-size-val">13</span>' +
118
+ '<button type="button" data-step="1" title="Larger">+</button>' +
119
+ '</div>';
120
+ popoverEl.appendChild(sizeRow);
121
+
122
+ sizeValEl = sizeRow.querySelector('.header-tui-font-popover-size-val');
123
+ var stepBtns = sizeRow.querySelectorAll('button');
124
+ sizeDecEl = stepBtns[0];
125
+ sizeIncEl = stepBtns[1];
126
+ sizeDecEl.addEventListener('click', function (e) {
127
+ e.stopPropagation();
128
+ var next = Math.max(MIN_SIZE, getTerminalFontSize() - 1);
129
+ applyTerminalFont(undefined, next);
130
+ persistTermFont(undefined, next);
131
+ });
132
+ sizeIncEl.addEventListener('click', function (e) {
133
+ e.stopPropagation();
134
+ var next = Math.min(MAX_SIZE, getTerminalFontSize() + 1);
135
+ applyTerminalFont(undefined, next);
136
+ persistTermFont(undefined, next);
137
+ });
138
+
139
+ document.body.appendChild(popoverEl);
140
+ return popoverEl;
141
+ }
142
+
143
+ function positionPopover() {
144
+ if (!popoverEl || !btnEl) return;
145
+ var r = btnEl.getBoundingClientRect();
146
+ popoverEl.style.left = '0px';
147
+ popoverEl.style.top = '0px';
148
+ var pw = popoverEl.offsetWidth || 220;
149
+ var ph = popoverEl.offsetHeight || 340;
150
+ var vw = window.innerWidth;
151
+ var vh = window.innerHeight;
152
+ var left = r.right - pw;
153
+ if (left < 8) left = 8;
154
+ if (left + pw > vw - 8) left = vw - pw - 8;
155
+ var top = r.bottom + 8;
156
+ if (top + ph > vh - 8) {
157
+ var flipped = r.top - ph - 8;
158
+ if (flipped >= 8) top = flipped;
159
+ else top = Math.max(8, vh - ph - 8);
160
+ }
161
+ popoverEl.style.left = left + 'px';
162
+ popoverEl.style.top = top + 'px';
163
+ }
164
+
165
+ function openPopover() {
166
+ if (popoverOpen) return;
167
+ buildPopover();
168
+ syncActiveFamily();
169
+ syncSize();
170
+ popoverEl.classList.add('visible');
171
+ positionPopover();
172
+ popoverOpen = true;
173
+ if (btnEl) btnEl.classList.add('active');
174
+
175
+ outsideHandler = function (ev) {
176
+ if (!popoverEl) return;
177
+ if (popoverEl.contains(ev.target)) return;
178
+ if (btnEl && btnEl.contains(ev.target)) return;
179
+ closePopover();
180
+ };
181
+ document.addEventListener('mousedown', outsideHandler, true);
182
+
183
+ keyHandler = function (ev) {
184
+ if (ev.key === 'Escape') closePopover();
185
+ };
186
+ document.addEventListener('keydown', keyHandler, true);
187
+ }
188
+
189
+ function closePopover() {
190
+ if (!popoverOpen) return;
191
+ if (popoverEl) popoverEl.classList.remove('visible');
192
+ if (btnEl) btnEl.classList.remove('active');
193
+ popoverOpen = false;
194
+ if (outsideHandler) {
195
+ document.removeEventListener('mousedown', outsideHandler, true);
196
+ outsideHandler = null;
197
+ }
198
+ if (keyHandler) {
199
+ document.removeEventListener('keydown', keyHandler, true);
200
+ keyHandler = null;
201
+ }
202
+ }
203
+
204
+ export function initHeaderTuiFont() {
205
+ if (initialized) return;
206
+ btnEl = document.getElementById('header-tui-font-btn');
207
+ if (!btnEl) return;
208
+ btnEl.addEventListener('click', function (e) {
209
+ e.stopPropagation();
210
+ if (popoverOpen) closePopover();
211
+ else openPopover();
212
+ });
213
+ // Reflect external changes back into the popover (re-renders only if
214
+ // it's currently open).
215
+ onTerminalFontChange(function () {
216
+ if (popoverOpen) {
217
+ syncActiveFamily();
218
+ syncSize();
219
+ }
220
+ });
221
+ initialized = true;
222
+ }
223
+
224
+ export function showHeaderTuiFont() {
225
+ if (!btnEl) return;
226
+ btnEl.classList.remove('hidden');
227
+ }
228
+
229
+ export function hideHeaderTuiFont() {
230
+ closePopover();
231
+ if (!btnEl) return;
232
+ btnEl.classList.add('hidden');
233
+ }
@@ -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.
@@ -18,6 +18,7 @@ import { getWs } from './ws-ref.js';
18
18
  import { store } from './store.js';
19
19
  import { getTerminalTheme } from './theme.js';
20
20
  import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
21
+ import { showHeaderTuiFont, hideHeaderTuiFont } from './header-tui-font.js';
21
22
  import { openArticle as openWhatsNewArticle } from './whats-new-article.js';
22
23
 
23
24
  // Stable id of the canonical "Why TUI mode?" article in
@@ -303,7 +304,10 @@ function fitNow() {
303
304
  if (!xterm || !fitAddon || !hostEl) return;
304
305
  syncHostBounds();
305
306
  try {
307
+ var prevCols = xterm.cols;
308
+ var prevRows = xterm.rows;
306
309
  fitAddon.fit();
310
+ var dimsChanged = (xterm.cols !== prevCols || xterm.rows !== prevRows);
307
311
  // Inform the server so its PTY's idea of cols/rows matches what xterm
308
312
  // just rendered. Without this, claude TUI redraws using stale dims.
309
313
  if (currentTermId != null && getWs() && getWs().readyState === 1) {
@@ -314,6 +318,14 @@ function fitNow() {
314
318
  rows: xterm.rows,
315
319
  }));
316
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");
317
329
  } catch (e) {}
318
330
  }
319
331
 
@@ -377,6 +389,7 @@ export function attachTuiView(terminalId) {
377
389
  if (currentTermId === terminalId && xterm) {
378
390
  if (hostEl) hostEl.style.display = "flex";
379
391
  hideGuiChrome(true);
392
+ showHeaderTuiFont();
380
393
  fitNow();
381
394
  try { xterm.focus(); } catch (e) {}
382
395
  return;
@@ -388,6 +401,7 @@ export function attachTuiView(terminalId) {
388
401
  if (!ensureHostEl()) return;
389
402
  hostEl.style.display = "flex";
390
403
  hideGuiChrome(true);
404
+ showHeaderTuiFont();
391
405
  syncHostBounds();
392
406
 
393
407
  currentTermId = terminalId;
@@ -437,6 +451,7 @@ export function detachTuiView() {
437
451
  teardownXterm();
438
452
  if (hostEl) hostEl.style.display = "none";
439
453
  hideGuiChrome(false);
454
+ hideHeaderTuiFont();
440
455
  }
441
456
 
442
457
  // Route a term_output frame to the embedded xterm if it belongs to the
@@ -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();
@@ -4,7 +4,7 @@
4
4
  import { refreshIcons } from './icons.js';
5
5
  import { showToast } from './utils.js';
6
6
  import { getChatLayout, setChatLayout } from './theme.js';
7
- import { applyTerminalFont, getTerminalFontFamily, getTerminalFontSize } from './terminal-prefs.js';
7
+ import { applyTerminalFont } from './terminal-prefs.js';
8
8
  import { showEmailSetupModal, getEmailAccountListCache } from './context-sources.js';
9
9
  import { setSTTLang } from './stt.js';
10
10
  import { userAvatarUrl } from './avatar.js';
@@ -234,47 +234,10 @@ export function initUserSettings(appCtx) {
234
234
  // Theme picker lives on the user island button now (palette icon).
235
235
  // No appearance-section mount required.
236
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
- });
277
- }
237
+ // Terminal font picker lives in the title bar (visible only while a
238
+ // TUI session is active). See lib/public/modules/header-tui-font.js.
239
+ // We still seed terminal-prefs from /api/profile below so the
240
+ // controls populate correctly when they appear.
278
241
 
279
242
  // Layout switcher (Bubble / Channel)
280
243
  var layoutSwitcher = document.getElementById('us-layout-switcher');
@@ -489,32 +452,12 @@ function populateAccount() {
489
452
  // Hide account section in single-user mode (no username)
490
453
  var accountNav = settingsEl.querySelector('[data-section="us-account"]');
491
454
  if (accountNav) accountNav.style.display = data.username ? '' : 'none';
492
- // Terminal font: seed in-memory prefs from server, populate inputs.
455
+ // Terminal font: seed in-memory prefs from server. Header-mounted
456
+ // controls (header-tui-font.js) listen to applyTerminalFont and
457
+ // sync their UI - we don't touch any DOM here.
493
458
  if (data.terminalFont && typeof data.terminalFont === "object") {
494
459
  applyTerminalFont(data.terminalFont.family, data.terminalFont.size);
495
460
  }
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());
518
461
  // Auto-continue toggle
519
462
  var acToggle = document.getElementById('us-auto-continue');
520
463
  if (acToggle) acToggle.checked = !!data.autoContinueOnRateLimit;
@@ -586,7 +586,7 @@ function createCodexQueryHandle(appServer, queryOpts) {
586
586
  done: false,
587
587
  aborted: false,
588
588
  loopStarted: false,
589
- model: queryOpts.model || "gpt-5.4",
589
+ model: queryOpts.model || "gpt-5.5",
590
590
  // Track incremental text deltas
591
591
  textBlocks: {}, // itemId -> true (text_start sent)
592
592
  textLengths: {}, // itemId -> last sent length
@@ -831,7 +831,7 @@ function createCodexQueryHandle(appServer, queryOpts) {
831
831
 
832
832
  // Start or resume thread
833
833
  var threadParams = {
834
- model: queryOpts.model || "gpt-5.4",
834
+ model: queryOpts.model || "gpt-5.5",
835
835
  sandbox: queryOpts.sandboxMode || "workspace-write",
836
836
  approvalPolicy: queryOpts.approvalPolicy || "on-failure",
837
837
  cwd: queryOpts.cwd,
@@ -1085,7 +1085,7 @@ function createCodexAdapter(opts) {
1085
1085
  function buildReadyResponse(skillNames) {
1086
1086
  return {
1087
1087
  models: _cachedModels,
1088
- defaultModel: "gpt-5.4",
1088
+ defaultModel: "gpt-5.5",
1089
1089
  skills: skillNames || [],
1090
1090
  slashCommands: skillNames || [],
1091
1091
  fastModeState: null,
@@ -1297,9 +1297,10 @@ function createCodexAdapter(opts) {
1297
1297
  throw createShutdownError();
1298
1298
  }
1299
1299
 
1300
- console.log("[codex] App-server initialized, models: gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2");
1300
+ console.log("[codex] App-server initialized, models: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2");
1301
1301
 
1302
1302
  _cachedModels = [
1303
+ "gpt-5.5",
1303
1304
  "gpt-5.4",
1304
1305
  "gpt-5.4-mini",
1305
1306
  "gpt-5.3-codex",
@@ -1387,7 +1388,7 @@ function createCodexAdapter(opts) {
1387
1388
  throw new Error("[yoke/codex] Adapter not initialized. Call init() first.");
1388
1389
  }
1389
1390
 
1390
- var model = queryOpts.model || "gpt-5.4";
1391
+ var model = queryOpts.model || "gpt-5.5";
1391
1392
  var ac = queryOpts.abortController || new AbortController();
1392
1393
  var activeEntry = {
1393
1394
  abort: function() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.39.0",
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",