clay-server 2.38.0 → 2.39.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,6 +151,12 @@ function attachConnection(ctx) {
151
151
  sendTo(ws, { type: "email_accounts_list", accounts: emailAccountsList, providers: emailAccounts.PROVIDER_PRESETS });
152
152
  sendTo(ws, { type: "notes_list", notes: nm.list() });
153
153
  sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
154
+ // Initial per-user preference: how to render Claude sessions.
155
+ if (usersModule && typeof usersModule.getClaudeOpenMode === "function") {
156
+ var _comUid = (wsUser && wsUser.id) || null;
157
+ var _comVal = _comUid ? usersModule.getClaudeOpenMode(_comUid) : "tui";
158
+ sendTo(ws, { type: "claude_open_mode_changed", claudeOpenMode: _comVal || "tui" });
159
+ }
154
160
  _loop.sendConnectionState(ws);
155
161
  if (_mcp) _mcp.sendConnectionState(ws);
156
162
  if (_notifications) _notifications.sendConnectionState(ws, sendTo);
@@ -201,7 +207,7 @@ function attachConnection(ctx) {
201
207
  }
202
208
  ws._clayActiveSession = active.localId;
203
209
  var _vendorCaps = (sm.capabilitiesByVendor && sm.capabilitiesByVendor[active.vendor || sm.defaultVendor || "claude"]) || {};
204
- sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, hasHistory: (active.history && active.history.length > 0), capabilities: _vendorCaps });
210
+ sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, hasHistory: (active.history && active.history.length > 0), capabilities: _vendorCaps, mode: active.mode || "gui", terminalId: typeof active.terminalId === "number" ? active.terminalId : null, runtimeMode: active.runtimeMode || null, runtimeTerminalId: typeof active.runtimeTerminalId === "number" ? active.runtimeTerminalId : null });
205
211
  // Send per-session context sources
206
212
  var sessionSources = loadContextSources(slug, active.localId);
207
213
  sendTo(ws, { type: "context_sources_state", active: sessionSources });
@@ -1,5 +1,6 @@
1
1
  var fs = require("fs");
2
2
  var path = require("path");
3
+ var crypto = require("crypto");
3
4
  var { execFileSync } = require("child_process");
4
5
  var { CODEX_DEFAULTS, getCodexConfig } = require("./codex-defaults");
5
6
 
@@ -99,6 +100,89 @@ function attachSessions(ctx) {
99
100
  var loadContextSources = ctx.loadContextSources;
100
101
  var saveContextSources = ctx.saveContextSources;
101
102
 
103
+ // Resolve the active user's Claude open-mode preference ('gui' or 'tui').
104
+ // Multi-user mode reads per-user storage; single-user mode falls back to
105
+ // the daemon-level default ('gui').
106
+ function getClaudeOpenModeForWs(ws) {
107
+ if (!usersModule || typeof usersModule.getClaudeOpenMode !== "function") return "tui";
108
+ var uid = ws && ws._clayUser ? ws._clayUser.id : null;
109
+ if (!uid) return "tui";
110
+ try { return usersModule.getClaudeOpenMode(uid) || "tui"; } catch (e) { return "tui"; }
111
+ }
112
+
113
+ // Spawn a transient PTY for "view this Claude GUI session as TUI" (the
114
+ // user's claudeOpenMode is 'tui'). The session itself stays a GUI session
115
+ // on disk; we only attach a runtime terminal so xterm can render
116
+ // `claude --resume <cliSessionId>`. When the PTY dies the runtime link
117
+ // clears and the next click can re-attach without converting the session.
118
+ function spawnRuntimeTuiPty(session, ws) {
119
+ if (!tm || !session || !session.cliSessionId) return null;
120
+ if (typeof session.runtimeTerminalId === "number") {
121
+ // A previous click already spawned one; reuse it. tm.attach will
122
+ // replay scrollback on the new client subscription.
123
+ return session.runtimeTerminalId;
124
+ }
125
+ var sid = session.cliSessionId;
126
+ var localId = session.localId;
127
+ var cmd = "claude --resume " + sid + "; exit\n";
128
+ var term = tm.create(80, 24, getOsUserInfoForWs(ws), ws, {
129
+ initialInput: cmd,
130
+ kind: "tui-session",
131
+ title: "claude (resume) " + sid.slice(0, 8),
132
+ onExit: function () {
133
+ // Don't delete the session record - the underlying GUI session is
134
+ // still real. Just drop the runtime link so the sidebar/icon can
135
+ // refresh.
136
+ var s = sm.sessions.get(localId);
137
+ if (s) {
138
+ s.runtimeTerminalId = null;
139
+ try { sm.broadcastSessionList(); } catch (e) {}
140
+ }
141
+ },
142
+ });
143
+ if (term) {
144
+ session.runtimeTerminalId = term.id;
145
+ return term.id;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ // In-place conversion of a TUI session to GUI when the user's preference
151
+ // is 'gui'. Reads the jsonl transcript through the existing import path
152
+ // (same code Import CLI uses), populates session.history, flips the mode
153
+ // to 'gui', and kills the PTY. Subsequent clicks render via the SDK chat.
154
+ function convertTuiSessionToGui(session) {
155
+ if (!session || session.cliSessionId == null) return;
156
+ var cliSess;
157
+ try { cliSess = require("./cli-sessions"); } catch (e) { return; }
158
+ var history = null;
159
+ try { history = cliSess.readCliSessionHistory(cwd, session.cliSessionId); } catch (e) { history = null; }
160
+ if (Array.isArray(history)) {
161
+ session.history = history;
162
+ }
163
+ session.mode = "gui";
164
+ if (typeof session.terminalId === "number" && tm) {
165
+ try { tm.close(session.terminalId); } catch (e) {}
166
+ }
167
+ session.terminalId = null;
168
+ try { sm.saveSessionFile(session); } catch (e) {}
169
+ }
170
+
171
+ // Compute the runtimeMode the client should render for this session given
172
+ // the user's current preference. Pure function over session + pref; the
173
+ // caller decides whether to also mutate state (spawn PTY, convert, etc.)
174
+ // via the helpers above.
175
+ function computeRuntimeMode(session, pref) {
176
+ if (!session) return "gui";
177
+ if (session.vendor && session.vendor !== "claude") return session.mode || "gui";
178
+ var effPref = (pref === "gui") ? "gui" : "tui";
179
+ if (session.mode === "tui") {
180
+ return effPref === "gui" ? "gui" : "tui";
181
+ }
182
+ // session.mode === 'gui'
183
+ return effPref === "tui" ? "tui" : "gui";
184
+ }
185
+
102
186
  function handleSessionsMessage(ws, msg) {
103
187
 
104
188
  if (msg.type === "push_subscribe") {
@@ -128,7 +212,57 @@ function attachSessions(ctx) {
128
212
  if (ws._clayUser && usersModule.isMultiUser()) sessionOpts.ownerId = ws._clayUser.id;
129
213
  if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility;
130
214
  if (msg.vendor) sessionOpts.vendor = msg.vendor;
131
- var newSess = sm.createSession(sessionOpts, ws);
215
+ // Mode resolution: codex sessions are always GUI (no TUI adapter).
216
+ // Claude sessions honor the explicit msg.mode if provided, otherwise
217
+ // fall back to the user's claudeOpenMode preference. This is what
218
+ // makes the sidebar's "Claude" icon button create the right kind of
219
+ // session without the client needing to know the preference.
220
+ var requestedMode;
221
+ if (msg.vendor === "codex") {
222
+ requestedMode = "gui";
223
+ } else if (msg.mode === "tui" || msg.mode === "gui") {
224
+ requestedMode = msg.mode;
225
+ } else {
226
+ requestedMode = getClaudeOpenModeForWs(ws);
227
+ }
228
+ var newSess;
229
+ if (requestedMode === "tui") {
230
+ // TUI sessions own their cliSessionId up-front so we can launch
231
+ // `claude --session-id <uuid>` and resume the same conversation
232
+ // from external terminals (claude --resume <uuid>) and from the
233
+ // jsonl watcher (~/.claude/projects/<cwd>/<uuid>.jsonl).
234
+ //
235
+ // Construction order matters: createSession() fires session_switched
236
+ // synchronously, so we must populate terminalId on the record before
237
+ // switching. Use createSessionRaw + switchSession to get the right
238
+ // ordering and avoid an extra rebroadcast.
239
+ sessionOpts.mode = "tui";
240
+ sessionOpts.cliSessionId = crypto.randomUUID();
241
+ sessionOpts.vendor = sessionOpts.vendor || "claude";
242
+ newSess = sm.createSessionRaw(sessionOpts);
243
+ if (tm) {
244
+ var tuiSid = newSess.cliSessionId;
245
+ var tuiLocalId = newSess.localId;
246
+ var tuiCmd = "claude --session-id " + tuiSid + "; exit\n";
247
+ var tuiTerm = tm.create(80, 24, getOsUserInfoForWs(ws), ws, {
248
+ initialInput: tuiCmd,
249
+ kind: "tui-session",
250
+ title: "claude " + tuiSid.slice(0, 8),
251
+ onExit: function () {
252
+ if (sm.sessions.has(tuiLocalId)) {
253
+ try { sm.deleteSessionQuiet(tuiLocalId); } catch (e) {}
254
+ try { sm.broadcastSessionList(); } catch (e) {}
255
+ }
256
+ },
257
+ });
258
+ if (tuiTerm) {
259
+ newSess.terminalId = tuiTerm.id;
260
+ }
261
+ }
262
+ sm.switchSession(newSess.localId, ws);
263
+ } else {
264
+ newSess = sm.createSession(sessionOpts, ws);
265
+ }
132
266
  ws._clayActiveSession = newSess.localId;
133
267
  // Apply project-level email defaults to new session
134
268
  if (typeof ctx._email === "object" && ctx._email.getEmailDefaults) {
@@ -194,6 +328,15 @@ function attachSessions(ctx) {
194
328
  deletableIds.push(bulkId);
195
329
  }
196
330
  if (deletableIds.length > 0) {
331
+ // TUI sessions: kill their PTYs before the records are wiped.
332
+ if (tm) {
333
+ for (var bdi = 0; bdi < deletableIds.length; bdi++) {
334
+ var bdTarget = sm.sessions.get(deletableIds[bdi]);
335
+ if (bdTarget && bdTarget.mode === "tui" && typeof bdTarget.terminalId === "number") {
336
+ try { tm.close(bdTarget.terminalId); } catch (e) {}
337
+ }
338
+ }
339
+ }
197
340
  sm.deleteSessionsBulk(deletableIds, ws);
198
341
  }
199
342
  return true;
@@ -324,6 +467,44 @@ function attachSessions(ctx) {
324
467
 
325
468
  if (msg.type === "switch_session") {
326
469
  if (msg.id && sm.sessions.has(msg.id)) {
470
+ // Apply the claudeOpenMode preference to the target Claude session
471
+ // before sm.switchSession fires session_switched. Two transforms:
472
+ //
473
+ // - born-GUI viewed under TUI pref: spawn a transient PTY running
474
+ // `claude --resume <cliSessionId>`; the session record stays GUI
475
+ // so a later pref flip back to GUI just hides the runtime link.
476
+ // - born-TUI viewed under GUI pref: in-place convert via the
477
+ // Import CLI code path (jsonl history -> session.history,
478
+ // session.mode -> 'gui', kill PTY). This is destructive but
479
+ // matches "TUI->GUI is what Import CLI was always for."
480
+ //
481
+ // runtimeMode / runtimeTerminalId are set on the session record so
482
+ // the session_switched and session_list broadcasts surface them to
483
+ // the client without sessions.js needing to know about the pref.
484
+ var xmTarget = sm.sessions.get(msg.id);
485
+ if (xmTarget && (xmTarget.vendor === "claude" || !xmTarget.vendor)) {
486
+ var xmPref = getClaudeOpenModeForWs(ws);
487
+ var xmRuntime = computeRuntimeMode(xmTarget, xmPref);
488
+ if (xmRuntime === "gui" && xmTarget.mode === "tui") {
489
+ convertTuiSessionToGui(xmTarget);
490
+ xmTarget.runtimeMode = null;
491
+ xmTarget.runtimeTerminalId = null;
492
+ } else if (xmRuntime === "tui" && xmTarget.mode === "gui" && xmTarget.cliSessionId) {
493
+ var xmRid = spawnRuntimeTuiPty(xmTarget, ws);
494
+ if (typeof xmRid === "number") {
495
+ xmTarget.runtimeMode = "tui";
496
+ xmTarget.runtimeTerminalId = xmRid;
497
+ } else {
498
+ xmTarget.runtimeMode = null;
499
+ xmTarget.runtimeTerminalId = null;
500
+ }
501
+ } else {
502
+ // Same-mode click. Don't leak a stale runtime override into the
503
+ // payload, but keep any background runtime PTY alive (it may be
504
+ // re-attached on the next pref flip).
505
+ xmTarget.runtimeMode = null;
506
+ }
507
+ }
327
508
  // If the target session's vendor doesn't own the currently cached
328
509
  // model, clear sm.currentModel so the UI and next query don't leak
329
510
  // the previous session's vendor-specific model into this one.
@@ -383,6 +564,12 @@ function attachSessions(ctx) {
383
564
  }
384
565
  }
385
566
  if (msg.id && sm.sessions.has(msg.id)) {
567
+ // TUI session: kill the underlying PTY before deleting the session
568
+ // record so the `claude` process is reaped and not left orphaned.
569
+ var dsTarget = sm.sessions.get(msg.id);
570
+ if (dsTarget && dsTarget.mode === "tui" && typeof dsTarget.terminalId === "number" && tm) {
571
+ try { tm.close(dsTarget.terminalId); } catch (e) {}
572
+ }
386
573
  sm.deleteSession(msg.id, ws);
387
574
  }
388
575
  return true;
@@ -1371,6 +1558,27 @@ function attachSessions(ctx) {
1371
1558
  return true;
1372
1559
  }
1373
1560
 
1561
+ if (msg.type === "set_claude_open_mode") {
1562
+ // Per-user preference: when Clay opens a Claude session, render it as
1563
+ // the SDK-driven custom chat ("gui") or as an embedded `claude` TUI
1564
+ // ("tui"). Applies to the next session open; currently displayed
1565
+ // sessions are not re-rendered retroactively.
1566
+ var comUserId = ws._clayUser ? ws._clayUser.id : null;
1567
+ if (!comUserId) {
1568
+ sendTo(ws, { type: "set_claude_open_mode_result", ok: false, error: "no_user" });
1569
+ return true;
1570
+ }
1571
+ var comResult = usersModule.setClaudeOpenMode(comUserId, msg.value);
1572
+ if (comResult && comResult.ok) {
1573
+ sendTo(ws, { type: "set_claude_open_mode_result", ok: true, claudeOpenMode: comResult.claudeOpenMode });
1574
+ // Echo as a "changed" broadcast for this user's other tabs/devices.
1575
+ sendTo(ws, { type: "claude_open_mode_changed", claudeOpenMode: comResult.claudeOpenMode });
1576
+ } else {
1577
+ sendTo(ws, { type: "set_claude_open_mode_result", ok: false, error: (comResult && comResult.error) || "unknown" });
1578
+ }
1579
+ return true;
1580
+ }
1581
+
1374
1582
  if (msg.type === "set_image_retention") {
1375
1583
  if (typeof opts.onSetImageRetention === "function") {
1376
1584
  var irResult = opts.onSetImageRetention(msg.days);
@@ -737,6 +737,24 @@
737
737
  flex-shrink: 0;
738
738
  }
739
739
  #vendor-toggle-wrap.hidden { display: none; }
740
+
741
+ /* Compact session-vendor icon shown next to the config-chip when the
742
+ full vendor-toggle is hidden (because the session's vendor is already
743
+ committed). Visual only - not clickable. */
744
+ #active-vendor-indicator {
745
+ display: inline-flex;
746
+ align-items: center;
747
+ margin-right: 6px;
748
+ opacity: 0.85;
749
+ flex-shrink: 0;
750
+ }
751
+ #active-vendor-indicator.hidden { display: none; }
752
+ #active-vendor-icon {
753
+ width: 18px;
754
+ height: 18px;
755
+ border-radius: 4px;
756
+ object-fit: cover;
757
+ }
740
758
  #vendor-toggle-wrap.locked {
741
759
  border: none;
742
760
  height: auto;
@@ -993,7 +993,7 @@
993
993
 
994
994
  .session-top-actions {
995
995
  display: grid;
996
- grid-template-columns: repeat(2, minmax(0, 1fr));
996
+ grid-template-columns: repeat(3, minmax(0, 1fr));
997
997
  gap: 2px;
998
998
  padding: 0 0 2px;
999
999
  }
@@ -1026,6 +1026,128 @@
1026
1026
  flex-shrink: 0;
1027
1027
  }
1028
1028
 
1029
+ .session-top-action-icon {
1030
+ width: 16px;
1031
+ height: 16px;
1032
+ flex-shrink: 0;
1033
+ border-radius: 4px;
1034
+ object-fit: cover;
1035
+ }
1036
+
1037
+ /* Split-button variant used by the Claude tile: main click area + a
1038
+ thin chevron strip on the right that opens the mode picker. The
1039
+ wrapping div carries the .session-top-action hover/opacity styling so
1040
+ the two children read as a single visual unit. */
1041
+ .session-top-action-split {
1042
+ display: flex;
1043
+ align-items: stretch;
1044
+ padding: 0;
1045
+ overflow: hidden;
1046
+ }
1047
+ .session-top-action-main {
1048
+ display: flex;
1049
+ align-items: center;
1050
+ gap: 10px;
1051
+ flex: 1 1 auto;
1052
+ min-width: 0;
1053
+ padding: 0 8px 0 12px;
1054
+ border: none;
1055
+ background: transparent;
1056
+ color: inherit;
1057
+ font: inherit;
1058
+ cursor: pointer;
1059
+ text-align: left;
1060
+ }
1061
+ .session-top-action-chevron {
1062
+ display: flex;
1063
+ align-items: center;
1064
+ justify-content: center;
1065
+ width: 22px;
1066
+ padding: 0;
1067
+ border: none;
1068
+ border-left: 1px solid var(--border-subtle);
1069
+ background: transparent;
1070
+ color: inherit;
1071
+ cursor: pointer;
1072
+ }
1073
+ .session-top-action-chevron .lucide,
1074
+ .session-top-action-chevron svg {
1075
+ width: 12px;
1076
+ height: 12px;
1077
+ }
1078
+ .session-top-action-split:hover .session-top-action-chevron {
1079
+ background: rgba(var(--overlay-rgb), 0.05);
1080
+ }
1081
+
1082
+ /* Claude mode dropdown menu (opened by the chevron).
1083
+ Theme-aware: mirrors .tool-palette-ctx-menu so it follows light/dark. */
1084
+ .claude-mode-menu {
1085
+ position: fixed;
1086
+ z-index: 9999;
1087
+ min-width: 200px;
1088
+ padding: 4px 0;
1089
+ border-radius: 10px;
1090
+ background: var(--sidebar-bg);
1091
+ border: 1px solid var(--border);
1092
+ box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.4);
1093
+ font-family: inherit;
1094
+ }
1095
+ .claude-mode-menu-item {
1096
+ display: flex;
1097
+ align-items: center;
1098
+ justify-content: space-between;
1099
+ width: 100%;
1100
+ padding: 8px 12px;
1101
+ border: none;
1102
+ background: none;
1103
+ color: var(--text-secondary);
1104
+ cursor: pointer;
1105
+ text-align: left;
1106
+ font: inherit;
1107
+ font-size: 13px;
1108
+ transition: background 0.15s;
1109
+ }
1110
+ .claude-mode-menu-item:hover {
1111
+ background: rgba(var(--overlay-rgb), 0.05);
1112
+ }
1113
+ .claude-mode-menu-label {
1114
+ font-weight: 500;
1115
+ }
1116
+ .claude-mode-menu-hint {
1117
+ color: var(--text-dimmer);
1118
+ font-size: 11px;
1119
+ margin-left: 12px;
1120
+ }
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
+
1029
1151
  .session-top-action:hover {
1030
1152
  background: var(--sidebar-hover);
1031
1153
  color: var(--text);
@@ -482,6 +482,9 @@
482
482
  <span class="vendor-toggle-label">Codex</span>
483
483
  </button>
484
484
  </div>
485
+ <div id="active-vendor-indicator" class="hidden" title="Session vendor">
486
+ <img id="active-vendor-icon" alt="">
487
+ </div>
485
488
  <div id="config-chip-wrap" class="hidden">
486
489
  <button id="config-chip" title="Model, mode, and effort settings">
487
490
  <i class="config-chip-icon" data-lucide="sliders-horizontal"></i>
@@ -1037,6 +1040,16 @@
1037
1040
  <span class="toggle-track"><span class="toggle-thumb"></span></span>
1038
1041
  </label>
1039
1042
  </div>
1043
+ <div class="settings-card">
1044
+ <label class="settings-toggle-row">
1045
+ <div>
1046
+ <span class="settings-label">Open Claude as terminal (TUI)</span>
1047
+ <div class="settings-hint">Render Claude sessions in an embedded `claude` terminal instead of Clay's chat UI. Keeps usage in the Interactive billing bucket (post 2026-06-15 Agent SDK split). Applies on the next session open.</div>
1048
+ </div>
1049
+ <input type="checkbox" id="us-claude-open-mode">
1050
+ <span class="toggle-track"><span class="toggle-thumb"></span></span>
1051
+ </label>
1052
+ </div>
1040
1053
  </div>
1041
1054
 
1042
1055
  <!-- Mates section -->
@@ -27,6 +27,7 @@ import { handleFsList, handleFsRead, handleFileChanged, handleDirChanged, handle
27
27
  import { isProjectSettingsOpen, handleInstructionsRead, handleInstructionsWrite, handleProjectEnv, handleProjectEnvSaved, handleProjectSharedEnv, handleProjectSharedEnvSaved, handleProjectOwnerChanged } from './project-settings.js';
28
28
  import { updateSettingsModels, updateSettingsStats, updateDaemonConfig, handleSetPinResult, handleKeepAwakeChanged, handleAutoContinueChanged, handleRestartResult, handleShutdownResult, handleSharedEnv, handleSharedEnvSaved, handleGlobalClaudeMdRead, handleGlobalClaudeMdWrite } from './server-settings.js';
29
29
  import { handleTermList, handleTermCreated, sendTerminalCommand, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed } from './terminal.js';
30
+ import { attachTuiView, detachTuiView, tuiHandleTermOutput, tuiHandleTermResized, tuiHandleTermExited, tuiHandleTermClosed } from './session-tui-view.js';
30
31
  import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult, handleEmailDefaults } from './context-sources.js';
31
32
  import { refreshEmailSettings } from './user-settings.js';
32
33
  import { handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted } from './sticky-notes.js';
@@ -589,7 +590,24 @@ export function processMessage(msg) {
589
590
  } else if (_prevSid) {
590
591
  delete store.get('sessionDrafts')[_prevSid];
591
592
  }
592
- store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing });
593
+ // runtimeMode/runtimeTerminalId take precedence over the session's
594
+ // born mode so the user's current claudeOpenMode preference applies
595
+ // to existing sessions too (born-GUI viewed under TUI pref runs
596
+ // claude --resume in xterm; born-TUI viewed under GUI pref was
597
+ // in-place converted to GUI by the server before this message).
598
+ var _effectiveMode = msg.runtimeMode || msg.mode || "gui";
599
+ var _effectiveTerminalId = (typeof msg.runtimeTerminalId === "number")
600
+ ? msg.runtimeTerminalId
601
+ : (typeof msg.terminalId === "number" ? msg.terminalId : null);
602
+ store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory });
603
+ // TUI sessions swap the chat UI for an embedded xterm running
604
+ // `claude` inside a real PTY. Mount or tear down before the rest of
605
+ // the chat-side bookkeeping runs so we don't waste work on hidden DOM.
606
+ if (_effectiveMode === "tui" && typeof _effectiveTerminalId === "number") {
607
+ attachTuiView(_effectiveTerminalId);
608
+ } else {
609
+ detachTuiView();
610
+ }
593
611
  if (msg.vendor) {
594
612
  if (!store.get('vendorSelectionLocked') || msg.hasHistory) {
595
613
  store.set({ currentVendor: msg.vendor });
@@ -621,11 +639,44 @@ export function processMessage(msg) {
621
639
  if (!msg.hasHistory && !msg.vendor) {
622
640
  // Preserve explicit pre-message vendor choice on brand-new sessions.
623
641
  }
624
- // Show vendor toggle only for new sessions (no history)
642
+ // Vendor toggle visibility + active-vendor indicator next to the
643
+ // model chip.
644
+ // - Session has an explicit vendor: hide the toggle (it's
645
+ // committed for this conversation) and show a small avatar
646
+ // next to the config chip so the user still sees which vendor
647
+ // is in use.
648
+ // - History without recorded vendor: show locked toggle, no icon
649
+ // (we don't know what to render).
650
+ // - Brand-new no-vendor session: show toggle, no icon.
625
651
  var _vtw = document.getElementById("vendor-toggle-wrap");
652
+ var _avi = document.getElementById("active-vendor-indicator");
653
+ var _avIcon = document.getElementById("active-vendor-icon");
626
654
  if (_vtw) {
627
- if (msg.hasHistory) { _vtw.classList.remove("hidden"); _vtw.classList.add("locked"); }
628
- else { _vtw.classList.remove("locked"); _vtw.classList.remove("hidden"); }
655
+ if (msg.vendor) {
656
+ _vtw.classList.add("hidden");
657
+ _vtw.classList.remove("locked");
658
+ } else if (msg.hasHistory) {
659
+ _vtw.classList.remove("hidden");
660
+ _vtw.classList.add("locked");
661
+ } else {
662
+ _vtw.classList.remove("locked");
663
+ _vtw.classList.remove("hidden");
664
+ }
665
+ }
666
+ if (_avi && _avIcon) {
667
+ if (msg.vendor === "claude") {
668
+ _avIcon.src = "/claude-code-avatar.png";
669
+ _avIcon.alt = "Claude";
670
+ _avi.title = "Claude session";
671
+ _avi.classList.remove("hidden");
672
+ } else if (msg.vendor === "codex") {
673
+ _avIcon.src = "/codex-avatar.png";
674
+ _avIcon.alt = "Codex";
675
+ _avi.title = "Codex session";
676
+ _avi.classList.remove("hidden");
677
+ } else {
678
+ _avi.classList.add("hidden");
679
+ }
629
680
  }
630
681
  // Session presence is now tracked server-side (user-presence.json)
631
682
  clearRemoteCursors();
@@ -1230,19 +1281,21 @@ export function processMessage(msg) {
1230
1281
  break;
1231
1282
 
1232
1283
  case "term_output":
1233
- handleTermOutput(msg);
1284
+ // TUI session view owns its terminal id exclusively; only fall
1285
+ // through to the bottom-panel handler when the id doesn't match.
1286
+ if (!tuiHandleTermOutput(msg)) handleTermOutput(msg);
1234
1287
  break;
1235
1288
 
1236
1289
  case "term_resized":
1237
- handleTermResized(msg);
1290
+ if (!tuiHandleTermResized(msg)) handleTermResized(msg);
1238
1291
  break;
1239
1292
 
1240
1293
  case "term_exited":
1241
- handleTermExited(msg);
1294
+ if (!tuiHandleTermExited(msg)) handleTermExited(msg);
1242
1295
  break;
1243
1296
 
1244
1297
  case "term_closed":
1245
- handleTermClosed(msg);
1298
+ if (!tuiHandleTermClosed(msg)) handleTermClosed(msg);
1246
1299
  break;
1247
1300
 
1248
1301
  case "notes_list":
@@ -1630,6 +1683,16 @@ export function processMessage(msg) {
1630
1683
  handleAutoContinueChanged(msg);
1631
1684
  break;
1632
1685
 
1686
+ case "set_claude_open_mode_result":
1687
+ case "claude_open_mode_changed":
1688
+ if (msg.claudeOpenMode === "tui" || msg.claudeOpenMode === "gui") {
1689
+ store.set({ claudeOpenMode: msg.claudeOpenMode });
1690
+ // Reflect into the user-settings toggle if it's already in the DOM.
1691
+ var _comToggle = document.getElementById("us-claude-open-mode");
1692
+ if (_comToggle) _comToggle.checked = msg.claudeOpenMode === "tui";
1693
+ }
1694
+ break;
1695
+
1633
1696
  case "restart_server_result":
1634
1697
  handleRestartResult(msg);
1635
1698
  break;
@@ -176,8 +176,16 @@ function hasBeta(name) {
176
176
 
177
177
  function rebuildModelList() {
178
178
  if (!configModelList) return;
179
- configModelList.innerHTML = "";
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.
183
+ var modelSection = configModelList.parentElement;
180
184
  var s = store.snap();
185
+ var hideModelPicker = s.activeSessionMode === "gui";
186
+ if (modelSection) modelSection.style.display = hideModelPicker ? "none" : "";
187
+ configModelList.innerHTML = "";
188
+ if (hideModelPicker) return;
181
189
  var list = s.currentModels.length > 0 ? s.currentModels : (s.currentModel ? [{ value: s.currentModel, displayName: s.currentModel }] : []);
182
190
  for (var i = 0; i < list.length; i++) {
183
191
  var item = list[i];
@@ -453,7 +461,9 @@ export function initPanels() {
453
461
  state.currentVendor !== prev.currentVendor ||
454
462
  state.codexApproval !== prev.codexApproval ||
455
463
  state.codexSandbox !== prev.codexSandbox ||
456
- state.codexWebSearch !== prev.codexWebSearch) {
464
+ state.codexWebSearch !== prev.codexWebSearch ||
465
+ state.sessionHasHistory !== prev.sessionHasHistory ||
466
+ state.activeSessionMode !== prev.activeSessionMode) {
457
467
  updateConfigChip();
458
468
  }
459
469
  });
@@ -0,0 +1,276 @@
1
+ // session-tui-view.js
2
+ //
3
+ // Renders a Claude Code TUI inside the main session view area when the
4
+ // active session is a `mode: 'tui'` session. The PTY itself is managed by
5
+ // the server's terminal-manager (same infra that powers the bottom-panel
6
+ // shell tabs); this module is responsible only for the embedded xterm and
7
+ // for relaying input/output/resize for the bound terminal.
8
+ //
9
+ // Lifecycle (driven by app-messages.js on session_switched):
10
+ // attachTuiView(terminalId) - mount xterm, send term_attach
11
+ // detachTuiView() - send term_detach, dispose xterm
12
+ //
13
+ // PTY survives detach (server keeps the terminal alive). On `/exit` or
14
+ // claude exit the server's onExit hook deletes the session and broadcasts
15
+ // the new session list; this view tears itself down via handleTermExited.
16
+
17
+ import { getWs } from './ws-ref.js';
18
+
19
+ // Claude TUI sessions intentionally ignore Clay's dark/light theme and
20
+ // always render with a classic black terminal look. The bottom-panel
21
+ // shell terminal still follows the theme; this is specific to the TUI
22
+ // session view so `claude` renders consistently across themes.
23
+ var TUI_TERMINAL_THEME = {
24
+ background: "#000000",
25
+ foreground: "#e5e5e5",
26
+ cursor: "#e5e5e5",
27
+ cursorAccent: "#000000",
28
+ selectionBackground: "#3a3a3a",
29
+ black: "#000000",
30
+ red: "#cd3131",
31
+ green: "#0dbc79",
32
+ yellow: "#e5e510",
33
+ blue: "#2472c8",
34
+ magenta: "#bc3fbc",
35
+ cyan: "#11a8cd",
36
+ white: "#e5e5e5",
37
+ brightBlack: "#666666",
38
+ brightRed: "#f14c4c",
39
+ brightGreen: "#23d18b",
40
+ brightYellow: "#f5f543",
41
+ brightBlue: "#3b8eea",
42
+ brightMagenta: "#d670d6",
43
+ brightCyan: "#29b8db",
44
+ brightWhite: "#ffffff",
45
+ };
46
+
47
+ var hostEl = null; // container div mounted over #messages
48
+ var xterm = null; // xterm.js instance
49
+ var fitAddon = null;
50
+ var webglAddon = null;
51
+ var currentTermId = null;
52
+ var resizeObserver = null;
53
+ var windowResizeBound = false;
54
+ function onWindowResize() {
55
+ if (currentTermId != null) fitNow();
56
+ }
57
+
58
+ function ensureHostEl() {
59
+ if (hostEl) return hostEl;
60
+ // Anchor the host to the chat content area (the bounding box of
61
+ // #messages) rather than the viewport. Using `position: fixed` worked
62
+ // visually but broke layout when the sidebar, header, or any side panel
63
+ // was open - the xterm slid under them. Re-position on every show via
64
+ // syncHostBounds() so resizes and panel toggles stay in sync.
65
+ hostEl = document.createElement("div");
66
+ hostEl.id = "tui-session-host";
67
+ hostEl.style.position = "fixed";
68
+ hostEl.style.display = "none";
69
+ hostEl.style.background = "#000000";
70
+ hostEl.style.zIndex = "5";
71
+ hostEl.style.overflow = "hidden";
72
+ hostEl.style.boxSizing = "border-box";
73
+ document.body.appendChild(hostEl);
74
+ return hostEl;
75
+ }
76
+
77
+ function syncHostBounds() {
78
+ if (!hostEl) return;
79
+ var messagesEl = document.getElementById("messages");
80
+ if (!messagesEl) return;
81
+ var r = messagesEl.getBoundingClientRect();
82
+ // Extend down to the bottom of the viewport so the empty band that used
83
+ // to sit below #messages (where #input-area lived before we hid it) gets
84
+ // covered by the same terminal background instead of showing through.
85
+ hostEl.style.top = r.top + "px";
86
+ hostEl.style.left = r.left + "px";
87
+ hostEl.style.width = r.width + "px";
88
+ hostEl.style.height = (window.innerHeight - r.top) + "px";
89
+ }
90
+
91
+ function hideGuiChrome(hide) {
92
+ var messagesEl = document.getElementById("messages");
93
+ var inputArea = document.getElementById("input-area");
94
+ if (messagesEl) messagesEl.style.visibility = hide ? "hidden" : "";
95
+ if (inputArea) inputArea.style.display = hide ? "none" : "";
96
+ var newMsgBtn = document.getElementById("new-msg-btn");
97
+ if (newMsgBtn) newMsgBtn.style.display = hide ? "none" : "";
98
+ }
99
+
100
+ function fitNow() {
101
+ if (!xterm || !fitAddon || !hostEl) return;
102
+ syncHostBounds();
103
+ try {
104
+ fitAddon.fit();
105
+ // Inform the server so its PTY's idea of cols/rows matches what xterm
106
+ // just rendered. Without this, claude TUI redraws using stale dims.
107
+ if (currentTermId != null && getWs() && getWs().readyState === 1) {
108
+ getWs().send(JSON.stringify({
109
+ type: "term_resize",
110
+ id: currentTermId,
111
+ cols: xterm.cols,
112
+ rows: xterm.rows,
113
+ }));
114
+ }
115
+ } catch (e) {}
116
+ }
117
+
118
+ function createXterm() {
119
+ if (typeof Terminal === "undefined") return null;
120
+ var theme = TUI_TERMINAL_THEME;
121
+ // Match the host background to the xterm theme so any sub-cell gap
122
+ // between the last rendered row and the host's bottom blends in.
123
+ if (hostEl) hostEl.style.background = theme.background;
124
+ var term = new Terminal({
125
+ cursorBlink: true,
126
+ fontSize: 13,
127
+ fontFamily: "'SF Mono', Menlo, Monaco, 'Courier New', monospace",
128
+ theme: theme,
129
+ scrollback: 5000,
130
+ });
131
+ if (typeof FitAddon !== "undefined") {
132
+ fitAddon = new FitAddon.FitAddon();
133
+ term.loadAddon(fitAddon);
134
+ }
135
+ if (typeof WebLinksAddon !== "undefined") {
136
+ try { term.loadAddon(new WebLinksAddon.WebLinksAddon()); } catch (e) {}
137
+ }
138
+ term.open(hostEl);
139
+ if (typeof WebglAddon !== "undefined") {
140
+ try {
141
+ webglAddon = new WebglAddon.WebglAddon();
142
+ webglAddon.onContextLoss(function () {
143
+ try { webglAddon.dispose(); } catch (e) {}
144
+ webglAddon = null;
145
+ });
146
+ term.loadAddon(webglAddon);
147
+ } catch (e) {}
148
+ }
149
+ // Route keystrokes back to the PTY.
150
+ term.onData(function (data) {
151
+ if (currentTermId == null) return;
152
+ var ws = getWs();
153
+ if (ws && ws.readyState === 1) {
154
+ ws.send(JSON.stringify({ type: "term_input", id: currentTermId, data: data }));
155
+ }
156
+ });
157
+ return term;
158
+ }
159
+
160
+ function teardownXterm() {
161
+ if (webglAddon) {
162
+ try { webglAddon.dispose(); } catch (e) {}
163
+ webglAddon = null;
164
+ }
165
+ if (xterm) {
166
+ try { xterm.dispose(); } catch (e) {}
167
+ xterm = null;
168
+ }
169
+ fitAddon = null;
170
+ }
171
+
172
+ export function attachTuiView(terminalId) {
173
+ if (typeof terminalId !== "number") return;
174
+ // Re-attaching to the same terminal: just refit and refocus.
175
+ if (currentTermId === terminalId && xterm) {
176
+ if (hostEl) hostEl.style.display = "";
177
+ hideGuiChrome(true);
178
+ fitNow();
179
+ try { xterm.focus(); } catch (e) {}
180
+ return;
181
+ }
182
+ // Switching to a different TUI terminal: tear down the old one cleanly.
183
+ if (currentTermId != null && currentTermId !== terminalId) {
184
+ detachTuiView();
185
+ }
186
+ if (!ensureHostEl()) return;
187
+ hostEl.style.display = "";
188
+ hideGuiChrome(true);
189
+ syncHostBounds();
190
+
191
+ currentTermId = terminalId;
192
+ if (!xterm) xterm = createXterm();
193
+ if (!xterm) return;
194
+
195
+ // Subscribe to the terminal's output stream on the server. The server
196
+ // replays its scrollback buffer on attach so we never start blank.
197
+ var ws = getWs();
198
+ if (ws && ws.readyState === 1) {
199
+ ws.send(JSON.stringify({ type: "term_attach", id: terminalId }));
200
+ }
201
+
202
+ // First fit pass; defer a second pass for layout to settle.
203
+ fitNow();
204
+ setTimeout(fitNow, 50);
205
+ try { xterm.focus(); } catch (e) {}
206
+
207
+ if (!resizeObserver && typeof ResizeObserver !== "undefined") {
208
+ // Watch the chat-content area, not the host: the host's size is
209
+ // derived from #messages via syncHostBounds, so observing it would
210
+ // miss the actual source of truth (sidebar toggles, panel opens, etc.)
211
+ var msgEl = document.getElementById("messages");
212
+ if (msgEl) {
213
+ resizeObserver = new ResizeObserver(function () { fitNow(); });
214
+ resizeObserver.observe(msgEl);
215
+ }
216
+ }
217
+ if (!windowResizeBound) {
218
+ window.addEventListener("resize", onWindowResize);
219
+ windowResizeBound = true;
220
+ }
221
+ }
222
+
223
+ export function detachTuiView() {
224
+ if (resizeObserver) {
225
+ try { resizeObserver.disconnect(); } catch (e) {}
226
+ resizeObserver = null;
227
+ }
228
+ if (currentTermId != null) {
229
+ var ws = getWs();
230
+ if (ws && ws.readyState === 1) {
231
+ try { ws.send(JSON.stringify({ type: "term_detach", id: currentTermId })); } catch (e) {}
232
+ }
233
+ }
234
+ currentTermId = null;
235
+ teardownXterm();
236
+ if (hostEl) hostEl.style.display = "none";
237
+ hideGuiChrome(false);
238
+ }
239
+
240
+ // Route a term_output frame to the embedded xterm if it belongs to the
241
+ // current TUI session. Returns true if consumed so app-messages can skip
242
+ // the bottom-panel handler.
243
+ export function tuiHandleTermOutput(msg) {
244
+ if (!msg || msg.id !== currentTermId || !xterm || !msg.data) return false;
245
+ xterm.write(msg.data);
246
+ return true;
247
+ }
248
+
249
+ export function tuiHandleTermResized(msg) {
250
+ if (!msg || msg.id !== currentTermId || !xterm) return false;
251
+ if (msg.cols > 0 && msg.rows > 0) {
252
+ try { xterm.resize(msg.cols, msg.rows); } catch (e) {}
253
+ }
254
+ return true;
255
+ }
256
+
257
+ export function tuiHandleTermExited(msg) {
258
+ if (!msg || msg.id !== currentTermId) return false;
259
+ if (xterm) {
260
+ try { xterm.write("\r\n\x1b[90m[claude exited - session will close]\x1b[0m\r\n"); } catch (e) {}
261
+ }
262
+ // The server's onExit hook deletes the session record and broadcasts a
263
+ // fresh session_list. The next session_switched (or empty state) will
264
+ // call detachTuiView for us.
265
+ return true;
266
+ }
267
+
268
+ export function tuiHandleTermClosed(msg) {
269
+ if (!msg || msg.id !== currentTermId) return false;
270
+ detachTuiView();
271
+ return true;
272
+ }
273
+
274
+ export function getActiveTuiTerminalId() {
275
+ return currentTermId;
276
+ }
@@ -297,20 +297,105 @@ function appendSessionCloseButton(el, session) {
297
297
  el.appendChild(closeBtn);
298
298
  }
299
299
 
300
+ // Dropdown anchored to the Claude split-button chevron. Renders two
301
+ // explicit choices (TUI / GUI) and dismisses on click-outside, Escape,
302
+ // or window blur. Kept local to this module since it's the only caller.
303
+ var _claudeModeMenu = null;
304
+ function closeClaudeModeMenu() {
305
+ if (_claudeModeMenu && _claudeModeMenu.parentNode) {
306
+ _claudeModeMenu.parentNode.removeChild(_claudeModeMenu);
307
+ }
308
+ _claudeModeMenu = null;
309
+ }
310
+ function openClaudeModeMenu(x, y) {
311
+ closeClaudeModeMenu();
312
+ var menu = document.createElement("div");
313
+ menu.className = "claude-mode-menu";
314
+ var items = [
315
+ { label: "Start as TUI", hint: "Real claude terminal", mode: "tui" },
316
+ { label: "Start as GUI", hint: "Clay chat UI", mode: "gui" },
317
+ ];
318
+ for (var i = 0; i < items.length; i++) {
319
+ (function (it) {
320
+ var b = document.createElement("button");
321
+ b.type = "button";
322
+ b.className = "claude-mode-menu-item";
323
+ b.innerHTML = '<span class="claude-mode-menu-label">' + it.label + '</span>' +
324
+ '<span class="claude-mode-menu-hint">' + it.hint + '</span>';
325
+ b.addEventListener("click", function (e) {
326
+ e.stopPropagation();
327
+ closeClaudeModeMenu();
328
+ if (getWs() && store.get('connected')) {
329
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: it.mode }));
330
+ }
331
+ });
332
+ menu.appendChild(b);
333
+ })(items[i]);
334
+ }
335
+ document.body.appendChild(menu);
336
+ // Clamp to viewport.
337
+ var rect = menu.getBoundingClientRect();
338
+ var px = x, py = y;
339
+ if (px + rect.width > window.innerWidth - 4) px = window.innerWidth - rect.width - 4;
340
+ if (py + rect.height > window.innerHeight - 4) py = window.innerHeight - rect.height - 4;
341
+ menu.style.left = px + "px";
342
+ menu.style.top = py + "px";
343
+ _claudeModeMenu = menu;
344
+ }
345
+ document.addEventListener("click", function () { closeClaudeModeMenu(); });
346
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeClaudeModeMenu(); });
347
+ window.addEventListener("blur", function () { closeClaudeModeMenu(); });
348
+ window.addEventListener("resize", function () { closeClaudeModeMenu(); });
349
+
300
350
  function renderSessionTopActions() {
301
351
  var wrap = document.createElement("div");
302
352
  wrap.className = "session-top-actions";
303
353
 
304
- var newBtn = document.createElement("button");
305
- newBtn.className = "session-top-action";
306
- newBtn.type = "button";
307
- newBtn.innerHTML = iconHtml("plus") + '<span>New Session</span>';
308
- newBtn.addEventListener("click", function () {
354
+ // Claude: split button. Default click starts a TUI session (the path
355
+ // most users want post 2026-06-15 Agent SDK billing split). The small
356
+ // chevron on the right opens a dropdown that lets the user explicitly
357
+ // pick TUI or GUI for this one session, bypassing the saved pref.
358
+ var claudeWrap = document.createElement("div");
359
+ claudeWrap.className = "session-top-action session-top-action-split";
360
+
361
+ var claudeMain = document.createElement("button");
362
+ claudeMain.className = "session-top-action-main";
363
+ claudeMain.type = "button";
364
+ claudeMain.title = "New Claude session (TUI)";
365
+ claudeMain.innerHTML = '<img src="/claude-code-avatar.png" class="session-top-action-icon" alt=""><span>Claude</span>';
366
+ claudeMain.addEventListener("click", function () {
309
367
  if (getWs() && store.get('connected')) {
310
- getWs().send(JSON.stringify({ type: "new_session" }));
368
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: "tui" }));
311
369
  }
312
370
  });
313
- wrap.appendChild(newBtn);
371
+ claudeWrap.appendChild(claudeMain);
372
+
373
+ var claudeChevron = document.createElement("button");
374
+ claudeChevron.className = "session-top-action-chevron";
375
+ claudeChevron.type = "button";
376
+ claudeChevron.title = "Choose session mode";
377
+ claudeChevron.innerHTML = iconHtml("chevron-down");
378
+ claudeChevron.addEventListener("click", function (e) {
379
+ e.stopPropagation();
380
+ var rect = claudeWrap.getBoundingClientRect();
381
+ openClaudeModeMenu(rect.left, rect.bottom + 4);
382
+ });
383
+ claudeWrap.appendChild(claudeChevron);
384
+
385
+ wrap.appendChild(claudeWrap);
386
+
387
+ // Codex: always GUI (no TUI adapter for Codex).
388
+ var codexBtn = document.createElement("button");
389
+ codexBtn.className = "session-top-action";
390
+ codexBtn.type = "button";
391
+ codexBtn.title = "New Codex session";
392
+ codexBtn.innerHTML = '<img src="/codex-avatar.png" class="session-top-action-icon" alt=""><span>Codex</span>';
393
+ codexBtn.addEventListener("click", function () {
394
+ if (getWs() && store.get('connected')) {
395
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "codex" }));
396
+ }
397
+ });
398
+ wrap.appendChild(codexBtn);
314
399
 
315
400
  var importBtn = document.createElement("button");
316
401
  importBtn.className = "session-top-action";
@@ -1049,6 +1134,9 @@ function renderSessionItem(s) {
1049
1134
  if (store.get('isMultiUserMode') && s.sessionVisibility === "private") {
1050
1135
  textHtml += '<span class="session-private-icon" title="Private session">' + iconHtml("lock") + '</span>';
1051
1136
  }
1137
+ if (s.mode === "tui") {
1138
+ textHtml += '<span class="session-tui-icon" title="Claude Code terminal session">' + iconHtml("terminal") + '</span>';
1139
+ }
1052
1140
  textHtml += highlightMatch(s.title || "New Session", searchQuery);
1053
1141
  textSpan.innerHTML = textHtml;
1054
1142
  el.appendChild(textSpan);
@@ -662,7 +662,12 @@ function updateTerminalBadge() {
662
662
  // --- Handle server messages ---
663
663
 
664
664
  export function handleTermList(msg) {
665
- var serverTerminals = msg.terminals || [];
665
+ // TUI session terminals are owned by session-tui-view.js and must not
666
+ // appear in the bottom terminal panel. Filter them out before any tab
667
+ // bookkeeping so the panel never tries to render or auto-attach them.
668
+ var serverTerminals = (msg.terminals || []).filter(function (t) {
669
+ return t && t.kind !== "tui-session";
670
+ });
666
671
  var serverIds = new Set();
667
672
 
668
673
  // Add/update tabs from server list
@@ -8,6 +8,7 @@ import { showEmailSetupModal, getEmailAccountListCache } from './context-sources
8
8
  import { setSTTLang } from './stt.js';
9
9
  import { userAvatarUrl } from './avatar.js';
10
10
  import { store } from './store.js';
11
+ import { getWs } from './ws-ref.js';
11
12
 
12
13
  var ctx = null;
13
14
  var settingsEl = null;
@@ -167,6 +168,22 @@ export function initUserSettings(appCtx) {
167
168
  });
168
169
  }
169
170
 
171
+ // Claude open mode toggle: GUI (default) vs TUI. The server persists the
172
+ // pref per user; the WS broadcast `claude_open_mode_changed` keeps the
173
+ // store and other tabs in sync.
174
+ var claudeOpenModeToggle = document.getElementById('us-claude-open-mode');
175
+ if (claudeOpenModeToggle) {
176
+ claudeOpenModeToggle.checked = store.get('claudeOpenMode') === 'tui';
177
+ claudeOpenModeToggle.addEventListener('change', function () {
178
+ var want = this.checked ? 'tui' : 'gui';
179
+ var ws = getWs();
180
+ if (ws && ws.readyState === 1) {
181
+ ws.send(JSON.stringify({ type: 'set_claude_open_mode', value: want }));
182
+ showToast(want === 'tui' ? 'Claude opens as terminal' : 'Claude opens as chat');
183
+ }
184
+ });
185
+ }
186
+
170
187
  // Mates UI toggle. Default-on, so flipping off hides every Mates
171
188
  // surface in the app (sidebar avatars, DM picker entry, home-hub strip)
172
189
  // via the body.mates-disabled CSS gate. Flipping back on restores the
package/lib/sdk-bridge.js CHANGED
@@ -910,7 +910,21 @@ function createSDKBridge(opts) {
910
910
  if (session.abortController === myAbortController) session.abortController = null;
911
911
  session.taskStopRequested = false;
912
912
  session.pendingPermissions = {};
913
- session.pendingAskUser = {};
913
+ // Preserve MCP-mode AskUserQuestion entries across turn boundaries.
914
+ // The MCP path is intentionally stateless: the tool returns immediately
915
+ // ("card posted, end your turn") and the user's answer is expected to
916
+ // arrive as a brand-new user_message on the *next* turn. That means the
917
+ // pending entry MUST survive this finally block. Without it, the
918
+ // ask_user_response handler can't find the toolId and silently drops
919
+ // the answer. canUseTool-mode entries (Claude's native path) still hold
920
+ // an open SDK permission callback that dies with the query, so those
921
+ // are correctly cleared here.
922
+ var keepAskUser = {};
923
+ for (var _tid in session.pendingAskUser) {
924
+ var _pending = session.pendingAskUser[_tid];
925
+ if (_pending && _pending.mode === "mcp") keepAskUser[_tid] = _pending;
926
+ }
927
+ session.pendingAskUser = keepAskUser;
914
928
  session.pendingElicitations = {};
915
929
 
916
930
  // Auto-continue on rate limit (scheduler sessions, or user setting)
package/lib/sessions.js CHANGED
@@ -246,6 +246,10 @@ function createSessionManager(opts) {
246
246
  favoriteOrder: typeof s.favoriteOrder === "number" ? s.favoriteOrder : null,
247
247
  unread: unreadMap[s.localId] || 0,
248
248
  vendor: s.vendor || null,
249
+ mode: s.mode || "gui",
250
+ terminalId: typeof s.terminalId === "number" ? s.terminalId : null,
251
+ runtimeMode: s.runtimeMode || null,
252
+ runtimeTerminalId: typeof s.runtimeTerminalId === "number" ? s.runtimeTerminalId : null,
249
253
  };
250
254
  }
251
255
 
@@ -289,7 +293,7 @@ function createSessionManager(opts) {
289
293
  localId: localId,
290
294
  queryInstance: null,
291
295
  messageQueue: null,
292
- cliSessionId: null,
296
+ cliSessionId: (sessionOpts && sessionOpts.cliSessionId) || null,
293
297
  blocks: {},
294
298
  sentToolResults: {},
295
299
  pendingPermissions: {},
@@ -308,6 +312,8 @@ function createSessionManager(opts) {
308
312
  bookmarked: false,
309
313
  favoriteOrder: null,
310
314
  vendor: (sessionOpts && sessionOpts.vendor) || null,
315
+ mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
316
+ terminalId: null,
311
317
  };
312
318
  sessions.set(localId, session);
313
319
  switchSession(localId, targetWs);
@@ -321,7 +327,7 @@ function createSessionManager(opts) {
321
327
  localId: localId,
322
328
  queryInstance: null,
323
329
  messageQueue: null,
324
- cliSessionId: null,
330
+ cliSessionId: (sessionOpts && sessionOpts.cliSessionId) || null,
325
331
  blocks: {},
326
332
  sentToolResults: {},
327
333
  pendingPermissions: {},
@@ -340,6 +346,8 @@ function createSessionManager(opts) {
340
346
  bookmarked: false,
341
347
  favoriteOrder: null,
342
348
  vendor: (sessionOpts && sessionOpts.vendor) || null,
349
+ mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
350
+ terminalId: null,
343
351
  };
344
352
  sessions.set(localId, session);
345
353
  return session;
@@ -423,7 +431,7 @@ function createSessionManager(opts) {
423
431
  var _capsByVendor = capabilitiesByVendor || {};
424
432
  var _sessionVendor = session.vendor || defaultVendor || "claude";
425
433
  var _vendorCaps = _capsByVendor[_sessionVendor] || {};
426
- _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing });
434
+ _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null });
427
435
  // Send vendor-specific slash commands
428
436
  var _vendorCmds = slashCommandsByVendor[_sessionVendor] || slashCommands || [];
429
437
  _send({ type: "slash_commands", commands: _vendorCmds, vendor: _sessionVendor });
@@ -16,10 +16,23 @@ function createTerminalManager(opts) {
16
16
  var nextId = 1;
17
17
  var terminals = new Map(); // id -> terminal session
18
18
 
19
- function create(cols, rows, osUserInfo, ownerWs) {
19
+ /**
20
+ * Create a PTY-backed terminal.
21
+ *
22
+ * opts (optional):
23
+ * - initialInput: string injected into the PTY right after spawn
24
+ * (used by TUI sessions to launch `claude --session-id <uuid>`).
25
+ * - title: initial title override.
26
+ * - kind: free-form tag (e.g. "tui-session") for callers to discriminate
27
+ * their terminals from generic shell tabs. Not used by the manager.
28
+ * - onExit(session): callback fired after the PTY exits and subscribers
29
+ * are notified. Used by TUI sessions to delete their session record so
30
+ * stale entries don't accumulate.
31
+ */
32
+ function create(cols, rows, osUserInfo, ownerWs, opts) {
20
33
  if (terminals.size >= MAX_TERMINALS) return null;
21
34
 
22
- var pty = createTerminal(cwd, cols, rows, osUserInfo);
35
+ var pty = createTerminal(cwd, cols, rows, osUserInfo, opts);
23
36
  if (!pty) return null;
24
37
 
25
38
  var id = nextId++;
@@ -31,11 +44,13 @@ function createTerminalManager(opts) {
31
44
  totalBytesWritten: 0,
32
45
  cols: cols || 80,
33
46
  rows: rows || 24,
34
- title: "Terminal " + id,
47
+ title: (opts && opts.title) || ("Terminal " + id),
48
+ kind: (opts && opts.kind) || "shell",
35
49
  exited: false,
36
50
  exitCode: null,
37
51
  subscribers: new Set(),
38
52
  ownerWs: ownerWs || null,
53
+ onExitHook: (opts && typeof opts.onExit === "function") ? opts.onExit : null,
39
54
  };
40
55
 
41
56
  pty.onData(function (data) {
@@ -68,6 +83,13 @@ function createTerminalManager(opts) {
68
83
 
69
84
  // Broadcast updated list
70
85
  send({ type: "term_list", terminals: list() });
86
+
87
+ // Caller-supplied hook: e.g. TUI session manager uses this to delete
88
+ // its session record once `claude` exits, so stale entries don't pile
89
+ // up in the sidebar.
90
+ if (session.onExitHook) {
91
+ try { session.onExitHook(session); } catch (err) {}
92
+ }
71
93
  });
72
94
 
73
95
  terminals.set(id, session);
@@ -173,6 +195,7 @@ function createTerminalManager(opts) {
173
195
  result.push({
174
196
  id: session.id,
175
197
  title: session.title,
198
+ kind: session.kind,
176
199
  exited: session.exited,
177
200
  });
178
201
  }
package/lib/terminal.js CHANGED
@@ -7,7 +7,15 @@ try {
7
7
 
8
8
  var { buildUserEnv } = require("./build-user-env");
9
9
 
10
- function createTerminal(cwd, cols, rows, osUserInfo) {
10
+ /**
11
+ * Spawn a PTY.
12
+ *
13
+ * opts (optional):
14
+ * - initialInput: string written to the PTY immediately after spawn.
15
+ * Used by TUI session mode to inject `claude --session-id <uuid>\n`
16
+ * so /exit drops back to the shell instead of killing the PTY.
17
+ */
18
+ function createTerminal(cwd, cols, rows, osUserInfo, opts) {
11
19
  if (!pty) return null;
12
20
 
13
21
  // Determine shell: prefer target user's shell, then $SHELL, then platform default
@@ -33,6 +41,10 @@ function createTerminal(cwd, cols, rows, osUserInfo) {
33
41
  var args = osUserInfo ? ["-l"] : [];
34
42
  var term = pty.spawn(shell, args, spawnOpts);
35
43
 
44
+ if (opts && opts.initialInput) {
45
+ try { term.write(opts.initialInput); } catch (e) {}
46
+ }
47
+
36
48
  return term;
37
49
  }
38
50
 
@@ -217,6 +217,45 @@ function attachPreferences(deps) {
217
217
  return { error: "User not found" };
218
218
  }
219
219
 
220
+ // --- Per-user Claude open mode ---
221
+ //
222
+ // Decides how Claude sessions are rendered when the user clicks into one:
223
+ // 'gui' - Clay's custom chat UI driven by the Claude Agent SDK (default).
224
+ // 'tui' - Embedded xterm running the real `claude` CLI. Keeps usage in
225
+ // the Interactive billing bucket post 2026-06-15.
226
+ //
227
+ // The preference applies on the next session open. Currently displayed
228
+ // sessions are not re-rendered retroactively. Existing TUI-born sessions
229
+ // always stay TUI regardless of preference (use Import CLI to bring a TUI
230
+ // conversation into the GUI flow).
231
+
232
+ function getClaudeOpenMode(userId) {
233
+ var data = loadUsers();
234
+ for (var i = 0; i < data.users.length; i++) {
235
+ if (data.users[i].id === userId) {
236
+ var m = data.users[i].claudeOpenMode;
237
+ // Default 'tui': post 2026-06-15 Agent SDK split, TUI keeps usage
238
+ // in the Interactive billing bucket so it's the right default for
239
+ // most users. Explicit 'gui' opts back into the SDK chat flow.
240
+ return (m === "gui") ? "gui" : "tui";
241
+ }
242
+ }
243
+ return "tui";
244
+ }
245
+
246
+ function setClaudeOpenMode(userId, mode) {
247
+ var normalized = (mode === "gui") ? "gui" : "tui";
248
+ var data = loadUsers();
249
+ for (var i = 0; i < data.users.length; i++) {
250
+ if (data.users[i].id === userId) {
251
+ data.users[i].claudeOpenMode = normalized;
252
+ saveUsers(data);
253
+ return { ok: true, claudeOpenMode: normalized };
254
+ }
255
+ }
256
+ return { error: "User not found" };
257
+ }
258
+
220
259
  // --- Per-user Mates UI toggle ---
221
260
  //
222
261
  // When false, the entire Mates surface (sidebar avatars, DM "Create a
@@ -321,6 +360,8 @@ function attachPreferences(deps) {
321
360
  setChatLayout: setChatLayout,
322
361
  getAutoContinue: getAutoContinue,
323
362
  setAutoContinue: setAutoContinue,
363
+ getClaudeOpenMode: getClaudeOpenMode,
364
+ setClaudeOpenMode: setClaudeOpenMode,
324
365
  getMatesEnabled: getMatesEnabled,
325
366
  setMatesEnabled: setMatesEnabled,
326
367
  getToolPalettes: getToolPalettes,
package/lib/users.js CHANGED
@@ -416,6 +416,8 @@ var getChatLayout = preferences.getChatLayout;
416
416
  var setChatLayout = preferences.setChatLayout;
417
417
  var getAutoContinue = preferences.getAutoContinue;
418
418
  var setAutoContinue = preferences.setAutoContinue;
419
+ var getClaudeOpenMode = preferences.getClaudeOpenMode;
420
+ var setClaudeOpenMode = preferences.setClaudeOpenMode;
419
421
  var getMatesEnabled = preferences.getMatesEnabled;
420
422
  var setMatesEnabled = preferences.setMatesEnabled;
421
423
  var getToolPalettes = preferences.getToolPalettes;
@@ -477,6 +479,8 @@ module.exports = {
477
479
  setMateOnboarded: setMateOnboarded,
478
480
  getAutoContinue: getAutoContinue,
479
481
  setAutoContinue: setAutoContinue,
482
+ getClaudeOpenMode: getClaudeOpenMode,
483
+ setClaudeOpenMode: setClaudeOpenMode,
480
484
  getMatesEnabled: getMatesEnabled,
481
485
  setMatesEnabled: setMatesEnabled,
482
486
  getToolPalettes: getToolPalettes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.38.0",
3
+ "version": "2.39.0-beta.1",
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",