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.
- package/lib/project-connection.js +7 -1
- package/lib/project-sessions.js +209 -1
- package/lib/public/css/input.css +18 -0
- package/lib/public/css/sidebar.css +123 -1
- package/lib/public/index.html +13 -0
- package/lib/public/modules/app-messages.js +71 -8
- package/lib/public/modules/app-panels.js +12 -2
- package/lib/public/modules/session-tui-view.js +276 -0
- package/lib/public/modules/sidebar-sessions.js +95 -7
- package/lib/public/modules/terminal.js +6 -1
- package/lib/public/modules/user-settings.js +17 -0
- package/lib/sdk-bridge.js +15 -1
- package/lib/sessions.js +11 -3
- package/lib/terminal-manager.js +26 -3
- package/lib/terminal.js +13 -1
- package/lib/users-preferences.js +41 -0
- package/lib/users.js +4 -0
- package/package.json +1 -1
|
@@ -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 });
|
package/lib/project-sessions.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/lib/public/css/input.css
CHANGED
|
@@ -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(
|
|
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);
|
package/lib/public/index.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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.
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/lib/terminal-manager.js
CHANGED
|
@@ -16,10 +16,23 @@ function createTerminalManager(opts) {
|
|
|
16
16
|
var nextId = 1;
|
|
17
17
|
var terminals = new Map(); // id -> terminal session
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/users-preferences.js
CHANGED
|
@@ -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