clay-server 2.40.0-beta.3 → 2.40.0-beta.4

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.
@@ -1034,91 +1034,6 @@
1034
1034
  object-fit: cover;
1035
1035
  }
1036
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
1037
  .session-top-action:hover {
1123
1038
  background: var(--sidebar-hover);
1124
1039
  color: var(--text);
@@ -8,6 +8,47 @@
8
8
  border-left: 1px solid var(--border-subtle);
9
9
  }
10
10
 
11
+ /* Lazy-resume bar: shown in place of the composer when a born-TUI session is
12
+ displayed read-only (no live PTY). Clicking Resume spawns claude --resume.
13
+ Toggled by body.tui-suspended (set in session-tui-view.js). */
14
+ #tui-resume-bar { display: none; }
15
+ body.tui-suspended #input-area { display: none !important; }
16
+ body.tui-suspended #tui-resume-bar {
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ gap: 12px;
21
+ flex-wrap: wrap;
22
+ padding: 14px 16px;
23
+ padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 14px);
24
+ border-top: 1px solid var(--border);
25
+ background: var(--bg-alt);
26
+ }
27
+ .tui-resume-btn {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ gap: 8px;
31
+ padding: 9px 18px;
32
+ border: none;
33
+ border-radius: 8px;
34
+ background: var(--accent);
35
+ color: #fff;
36
+ font-size: 14px;
37
+ font-weight: 600;
38
+ cursor: pointer;
39
+ -webkit-tap-highlight-color: transparent;
40
+ }
41
+ .tui-resume-btn:hover { filter: brightness(1.08); }
42
+ .tui-resume-btn:active { filter: brightness(0.95); }
43
+ .tui-resume-btn .lucide { width: 16px; height: 16px; }
44
+ .tui-resume-hint {
45
+ font-size: 12px;
46
+ color: var(--text-dimmer);
47
+ }
48
+ @media (max-width: 768px) {
49
+ .tui-resume-hint { display: none; }
50
+ }
51
+
11
52
  /* Policy notice that sits above the embedded xterm in fullscreen TUI
12
53
  sessions, explaining why this mode exists (post-2026-06-15 Agent SDK
13
54
  billing split). Thin so it doesn't eat terminal real estate. */
@@ -362,6 +362,7 @@
362
362
  <span class="header-title" id="header-title">Connecting...</span>
363
363
  <button id="header-info-btn" type="button" title="Session info"><i data-lucide="info"></i></button>
364
364
  <button id="header-rename-btn" type="button" title="Rename session"><i data-lucide="pencil"></i></button>
365
+ <button type="button" id="header-tui-close-btn" class="header-tui-close-btn hidden" title="Close terminal (keeps history, resume anytime)" aria-label="Close terminal"><i data-lucide="power"></i></button>
365
366
  <div id="mate-mobile-title" class="mate-mobile-title hidden">
366
367
  <img id="mate-mobile-avatar" class="mate-mobile-avatar" alt="">
367
368
  <span id="mate-mobile-name" class="mate-mobile-name"></span>
@@ -855,7 +856,7 @@
855
856
  <button class="file-viewer-btn" id="terminal-close" title="Hide panel"><i data-lucide="minus"></i></button>
856
857
  </div>
857
858
  </div>
858
- <div id="terminal-toolbar" class="hidden">
859
+ <div id="terminal-toolbar" class="term-toolbar hidden">
859
860
  <button class="term-key" data-key="tab">Tab</button>
860
861
  <button class="term-key term-key-toggle" data-key="ctrl">Ctrl</button>
861
862
  <button class="term-key" data-key="esc">Esc</button>
@@ -27,7 +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
+ import { attachTuiView, detachTuiView, setTuiSuspendedView, tuiHandleTermOutput, tuiHandleTermResized, tuiHandleTermExited, tuiHandleTermClosed } from './session-tui-view.js';
31
31
  import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed } from './tui-attention.js';
32
32
  import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult, handleEmailDefaults } from './context-sources.js';
33
33
  import { refreshEmailSettings } from './user-settings.js';
@@ -620,6 +620,9 @@ export function processMessage(msg) {
620
620
  } else {
621
621
  detachTuiView();
622
622
  }
623
+ // Born-TUI session with no live PTY: read-only transcript + Resume bar
624
+ // (the composer is hidden; clicking Resume spawns claude --resume).
625
+ setTuiSuspendedView(!!msg.tuiSuspended, msg.id);
623
626
  if (msg.vendor) {
624
627
  if (!store.get('vendorSelectionLocked') || msg.hasHistory) {
625
628
  store.set({ currentVendor: msg.vendor });
@@ -4,6 +4,7 @@ import { renderPicker as renderContextPicker } from './context-sources.js';
4
4
  import { checkForMention, showMentionMenu, hideMentionMenu, isMentionMenuVisible, mentionMenuKeydown, setMentionAtIdx, parseMentionFromInput, clearMentionState, stickyReapplyMention, sendMention, sendUserMention, renderMentionUser, renderUserMention, removeMentionChip } from './mention.js';
5
5
  import { store } from './store.js';
6
6
  import { mateAvatarUrl } from './avatar.js';
7
+ import { tuiIsActive, tuiSubmitText } from './session-tui-view.js';
7
8
 
8
9
  var ctx;
9
10
 
@@ -101,6 +102,28 @@ export function sendMessage() {
101
102
  hideSlashMenu();
102
103
  if (ctx.hideSuggestionChips) ctx.hideSuggestionChips();
103
104
 
105
+ // TUI session: on mobile the GUI composer stays visible so the native IME
106
+ // can compose Korean/CJK (xterm's hidden textarea can't on mobile WebKit).
107
+ // Forward the typed line straight to the PTY instead of the SDK message
108
+ // path - no chat bubble, the terminal renders its own echo. Attached files
109
+ // were uploaded to a path (uploadFile -> pendingFiles); the claude CLI
110
+ // accepts file paths in the prompt, so append them (quoted if they contain
111
+ // spaces). Pasted inline images have no path and aren't supported here.
112
+ if (tuiIsActive()) {
113
+ var tuiParts = [];
114
+ if (text) tuiParts.push(text);
115
+ for (var pf = 0; pf < pendingFiles.length; pf++) {
116
+ var pfPath = pendingFiles[pf].path;
117
+ if (pfPath) tuiParts.push(/\s/.test(pfPath) ? '"' + pfPath + '"' : pfPath);
118
+ }
119
+ tuiSubmitText(tuiParts.join(" "));
120
+ ctx.inputEl.value = "";
121
+ sendInputSync();
122
+ clearPendingImages();
123
+ autoResize();
124
+ return;
125
+ }
126
+
104
127
  if (text === "/clear") {
105
128
  ctx.inputEl.value = "";
106
129
  clearPendingImages();
@@ -521,8 +544,11 @@ var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
521
544
 
522
545
  // --- File upload ---
523
546
  function uploadFile(file) {
547
+ // Clipboard blobs may have no name; synthesize one so the server can store
548
+ // and name the file (the attach button passes real File objects).
549
+ var uploadName = file.name || ("upload-" + Date.now());
524
550
  if (file.size > MAX_UPLOAD_BYTES) {
525
- if (ctx.addSystemMessage) ctx.addSystemMessage("File too large (max 50MB): " + file.name, true);
551
+ if (ctx.addSystemMessage) ctx.addSystemMessage("File too large (max 50MB): " + uploadName, true);
526
552
  return;
527
553
  }
528
554
  uploadingCount++;
@@ -541,10 +567,10 @@ function uploadFile(file) {
541
567
  if (xhr.status === 200) {
542
568
  try {
543
569
  var resp = JSON.parse(xhr.responseText);
544
- pendingFiles.push({ name: resp.name || file.name, path: resp.path });
570
+ pendingFiles.push({ name: resp.name || uploadName, path: resp.path });
545
571
  } catch (e) {}
546
572
  } else {
547
- if (ctx.addSystemMessage) ctx.addSystemMessage("Upload failed: " + file.name, true);
573
+ if (ctx.addSystemMessage) ctx.addSystemMessage("Upload failed: " + uploadName, true);
548
574
  }
549
575
  renderInputPreviews();
550
576
  if (ctx.processing && ctx.setSendBtnMode) {
@@ -553,18 +579,25 @@ function uploadFile(file) {
553
579
  };
554
580
  xhr.onerror = function () {
555
581
  uploadingCount--;
556
- if (ctx.addSystemMessage) ctx.addSystemMessage("Upload failed: " + file.name, true);
582
+ if (ctx.addSystemMessage) ctx.addSystemMessage("Upload failed: " + uploadName, true);
557
583
  renderInputPreviews();
558
584
  if (ctx.processing && ctx.setSendBtnMode) {
559
585
  ctx.setSendBtnMode(hasSendableContent() ? "send" : "stop");
560
586
  }
561
587
  };
562
- xhr.send(JSON.stringify({ name: file.name, data: b64 }));
588
+ xhr.send(JSON.stringify({ name: uploadName, data: b64 }));
563
589
  };
564
590
  reader.readAsDataURL(file);
565
591
  }
566
592
 
567
593
  function readImageBlob(blob) {
594
+ // TUI sessions take file PATHS in the prompt, not inline base64 images.
595
+ // Upload the image to a path (pendingFiles) so the typed line can reference
596
+ // it; the claude CLI then reads the file. No client-side resize needed.
597
+ if (tuiIsActive()) {
598
+ uploadFile(blob);
599
+ return;
600
+ }
568
601
  var reader = new FileReader();
569
602
  reader.onload = function (ev) {
570
603
  var dataUrl = ev.target.result;
@@ -1027,6 +1060,11 @@ export function initInput(_ctx) {
1027
1060
  } else if (val === "/") {
1028
1061
  showSlashMenu("");
1029
1062
  hideMentionMenu();
1063
+ } else if (tuiIsActive()) {
1064
+ // TUI session: the composer is a plain conduit to the PTY. Mentions
1065
+ // (@mate) don't apply, so don't pop the menu on "@".
1066
+ hideSlashMenu();
1067
+ hideMentionMenu();
1030
1068
  } else {
1031
1069
  hideSlashMenu();
1032
1070
  // Check for @mention
@@ -20,6 +20,8 @@ import { getTerminalTheme } from './theme.js';
20
20
  import { getTerminalFontFamily, getTerminalFontSize, onTerminalFontChange } from './terminal-prefs.js';
21
21
  import { showHeaderTuiFont, hideHeaderTuiFont } from './header-tui-font.js';
22
22
  import { openArticle as openWhatsNewArticle } from './whats-new-article.js';
23
+ import { createKeyToolbar, TERMINAL_TOOLBAR_HTML } from './terminal-toolbar.js';
24
+ import { refreshIcons, iconHtml } from './icons.js';
23
25
 
24
26
  // Stable id of the canonical "Why TUI mode?" article in
25
27
  // lib/whats-new-content.js. The TUI policy notice's "Learn more" button
@@ -32,6 +34,14 @@ var TUI_POLICY_ARTICLE_ID = "2026-06-tui-default";
32
34
 
33
35
  var hostEl = null; // container div mounted over #messages
34
36
  var xtermContainerEl = null;
37
+ var keyToolbar = null; // shared mobile control-key bar (terminal-toolbar.js)
38
+ var isTouchDevice = "ontouchstart" in window;
39
+ // Mobile input strategy: iOS WebKit (and other mobile IMEs) don't fire usable
40
+ // composition events on xterm's hidden helper textarea, so Korean/CJK input
41
+ // reaches the PTY as decomposed jamo. Rather than reinvent an input surface,
42
+ // we keep the regular GUI composer (#input) visible below the terminal on
43
+ // touch devices and forward its send into the PTY (input.js -> tuiSubmitText).
44
+ // xterm is render-only on mobile.
35
45
 
36
46
  // The TUI policy notice's "Learn more" button opens the canonical
37
47
  // "Why TUI mode?" entry in the What's New blog viewer. The full
@@ -46,6 +56,14 @@ var xterm = null; // xterm.js instance
46
56
  var fitAddon = null;
47
57
  var webglAddon = null;
48
58
  var currentTermId = null;
59
+ // IME composition state. Mobile keyboards (and CJK input generally) compose
60
+ // a character from several keystrokes; xterm's onData fires for each
61
+ // intermediate jamo/kana, which on the PTY shows up as decomposed text
62
+ // (e.g. Korean "안녕" arriving as "ㅇㅏㄴㄴㅕㅇ"). We gate onData while a
63
+ // composition is active and emit the finished string on compositionend.
64
+ var imeComposing = false;
65
+ var imeLastComposed = "";
66
+ var imeLastComposedAt = 0;
49
67
  var resizeObserver = null;
50
68
  var windowResizeBound = false;
51
69
  var resizeDebounce = null;
@@ -113,6 +131,22 @@ function ensureHostEl() {
113
131
  // so cols/rows stay correct.
114
132
  hostEl.style.padding = "4px";
115
133
 
134
+ // Mobile control-key bar at the top of the TUI: soft keyboards lack
135
+ // Esc/Tab/Ctrl/arrows, which the Claude TUI relies on. Reuses the shared
136
+ // terminal-toolbar component (same markup/keys as the bottom-panel shell).
137
+ // Touch devices only; on desktop a hardware keyboard already has the keys.
138
+ if (isTouchDevice) {
139
+ var toolbarEl = document.createElement("div");
140
+ toolbarEl.id = "tui-key-toolbar";
141
+ toolbarEl.className = "term-toolbar";
142
+ toolbarEl.innerHTML = TERMINAL_TOOLBAR_HTML;
143
+ hostEl.appendChild(toolbarEl);
144
+ keyToolbar = createKeyToolbar({
145
+ toolbar: toolbarEl,
146
+ send: sendTermInput,
147
+ });
148
+ }
149
+
116
150
  // Policy notice: explains why TUI mode exists (Anthropic split Agent
117
151
  // SDK usage into a separate billing bucket on 2026-06-15; running
118
152
  // `claude` in a real terminal keeps usage in the Interactive bucket).
@@ -144,6 +178,30 @@ function ensureHostEl() {
144
178
  xtermContainerEl.style.position = "relative";
145
179
  hostEl.appendChild(xtermContainerEl);
146
180
 
181
+ // Mobile: route typing through the regular GUI composer (#input). Its
182
+ // native textarea composes Korean/CJK correctly, unlike xterm's hidden
183
+ // helper textarea on mobile WebKit. On touch devices the composer stays
184
+ // visible below the terminal (see hideGuiChrome) and its send is
185
+ // intercepted into the PTY (see input.js -> tuiSubmitText). Tapping the
186
+ // terminal focuses the composer so the keyboard opens, and the toolbar's
187
+ // sticky Ctrl is applied to composer letters here (Ctrl+C etc.).
188
+ if (isTouchDevice) {
189
+ xtermContainerEl.addEventListener("click", focusGuiComposer);
190
+ var composer = document.getElementById("input");
191
+ if (composer) {
192
+ composer.addEventListener("keydown", function (e) {
193
+ if (currentTermId == null) return;
194
+ if (e.key && e.key.length === 1 && keyToolbar && keyToolbar.takeCtrl()) {
195
+ var cc = e.key.toUpperCase().charCodeAt(0);
196
+ if (cc >= 65 && cc <= 90) {
197
+ e.preventDefault();
198
+ sendTermInput(String.fromCharCode(cc - 64));
199
+ }
200
+ }
201
+ }, true);
202
+ }
203
+ }
204
+
147
205
  // Paste-image handling. Two paths cover the common platforms:
148
206
  //
149
207
  // 1. `paste` event in capture phase - covers Cmd+V on macOS and
@@ -282,22 +340,36 @@ function syncHostBounds() {
282
340
  var messagesEl = document.getElementById("messages");
283
341
  if (!messagesEl) return;
284
342
  var r = messagesEl.getBoundingClientRect();
285
- // Extend down to the bottom of the viewport so the empty band that used
286
- // to sit below #messages (where #input-area lived before we hid it) gets
287
- // covered by the same terminal background instead of showing through.
288
343
  hostEl.style.top = r.top + "px";
289
344
  hostEl.style.left = r.left + "px";
290
345
  hostEl.style.width = r.width + "px";
291
- hostEl.style.height = (window.innerHeight - r.top) + "px";
346
+ // Desktop hides the GUI composer and extends the terminal to the viewport
347
+ // bottom (covering the band where #input-area used to sit). Mobile keeps
348
+ // the composer visible below the terminal, so end the host at #messages'
349
+ // own bottom and leave the composer its space.
350
+ if (isTouchDevice) {
351
+ hostEl.style.height = r.height + "px";
352
+ } else {
353
+ hostEl.style.height = Math.max(0, window.innerHeight - r.top) + "px";
354
+ }
292
355
  }
293
356
 
294
357
  function hideGuiChrome(hide) {
295
358
  var messagesEl = document.getElementById("messages");
296
359
  var inputArea = document.getElementById("input-area");
297
360
  if (messagesEl) messagesEl.style.visibility = hide ? "hidden" : "";
298
- if (inputArea) inputArea.style.display = hide ? "none" : "";
361
+ // Mobile keeps the composer visible during TUI so the native IME can
362
+ // compose Korean/CJK; its send is routed to the PTY. Desktop hides it and
363
+ // types straight into xterm.
364
+ if (inputArea && !isTouchDevice) inputArea.style.display = hide ? "none" : "";
299
365
  var newMsgBtn = document.getElementById("new-msg-btn");
300
366
  if (newMsgBtn) newMsgBtn.style.display = hide ? "none" : "";
367
+ // On mobile, mark the body while the composer is acting as a TUI conduit so
368
+ // CSS hides composer controls that don't apply (schedule, mention, model/
369
+ // vendor config). Attach + voice stay available.
370
+ if (isTouchDevice && document.body) {
371
+ document.body.classList.toggle("tui-composer-active", hide);
372
+ }
301
373
  }
302
374
 
303
375
  function fitNow() {
@@ -350,6 +422,8 @@ function createXterm() {
350
422
  try { term.loadAddon(new WebLinksAddon.WebLinksAddon()); } catch (e) {}
351
423
  }
352
424
  term.open(xtermContainerEl || hostEl);
425
+ bindImeComposition(term);
426
+ if (keyToolbar) keyToolbar.bindXterm(term);
353
427
  if (typeof WebglAddon !== "undefined") {
354
428
  try {
355
429
  webglAddon = new WebglAddon.WebglAddon();
@@ -360,15 +434,148 @@ function createXterm() {
360
434
  term.loadAddon(webglAddon);
361
435
  } catch (e) {}
362
436
  }
363
- // Route keystrokes back to the PTY.
437
+ // Route keystrokes back to the PTY. Suppress while an IME composition is
438
+ // in flight (the intermediate jamo/kana would otherwise reach the PTY as
439
+ // decomposed text); the finished string is sent from compositionend.
364
440
  term.onData(function (data) {
365
441
  if (currentTermId == null) return;
442
+ if (imeComposing) return;
443
+ // Drop xterm's own echo of the just-composed string (it re-emits the
444
+ // finalized text via onData right after compositionend).
445
+ if (data && data === imeLastComposed && (Date.now() - imeLastComposedAt) < 120) {
446
+ imeLastComposed = "";
447
+ return;
448
+ }
449
+ sendTermInput(data);
450
+ });
451
+ return term;
452
+ }
453
+
454
+ function sendTermInput(data) {
455
+ if (currentTermId == null || !data) return;
456
+ var ws = getWs();
457
+ if (ws && ws.readyState === 1) {
458
+ ws.send(JSON.stringify({ type: "term_input", id: currentTermId, data: data }));
459
+ }
460
+ }
461
+
462
+ // Focus the GUI composer (touch) or xterm (desktop). On mobile the composer
463
+ // owns input so the IME composes in a real textarea; focusing xterm's hidden
464
+ // textarea is what breaks Korean composition in the first place.
465
+ function focusGuiComposer() {
466
+ try {
467
+ if (isTouchDevice) {
468
+ var composer = document.getElementById("input");
469
+ if (composer) composer.focus();
470
+ } else if (xterm) {
471
+ xterm.focus();
472
+ }
473
+ } catch (e) {}
474
+ }
475
+
476
+ // --- Title-bar "Close" button (live TUI only) ---
477
+ // Explicitly closes the running PTY now (suspend_tui_session) instead of
478
+ // waiting for the idle sweep; the session drops to read-only history + Resume.
479
+ var closeBtnBound = false;
480
+ function bindCloseBtn() {
481
+ if (closeBtnBound) return;
482
+ var btn = document.getElementById("header-tui-close-btn");
483
+ if (!btn) return;
484
+ closeBtnBound = true;
485
+ btn.addEventListener("click", function () {
486
+ var sid = store.get("activeSessionId");
487
+ if (sid == null) return;
366
488
  var ws = getWs();
367
489
  if (ws && ws.readyState === 1) {
368
- ws.send(JSON.stringify({ type: "term_input", id: currentTermId, data: data }));
490
+ ws.send(JSON.stringify({ type: "suspend_tui_session", id: sid }));
369
491
  }
370
492
  });
371
- return term;
493
+ }
494
+ function showHeaderTuiClose() {
495
+ bindCloseBtn();
496
+ var btn = document.getElementById("header-tui-close-btn");
497
+ if (btn) btn.classList.remove("hidden");
498
+ }
499
+ function hideHeaderTuiClose() {
500
+ var btn = document.getElementById("header-tui-close-btn");
501
+ if (btn) btn.classList.add("hidden");
502
+ }
503
+
504
+ // True while a TUI session is mounted. input.js uses this to route the GUI
505
+ // composer's send into the PTY instead of the normal SDK message path.
506
+ export function tuiIsActive() {
507
+ return currentTermId != null;
508
+ }
509
+
510
+ // Submit a line typed in the GUI composer to the TUI's PTY (text + Enter).
511
+ export function tuiSubmitText(text) {
512
+ if (currentTermId == null) return;
513
+ if (text) sendTermInput(text);
514
+ sendTermInput("\r");
515
+ }
516
+
517
+ // --- Lazy-resume "suspended" view ---
518
+ // A born-TUI session whose PTY isn't running is shown as a read-only
519
+ // transcript (server hydrates history) with the composer hidden and a Resume
520
+ // bar in its place. Clicking Resume asks the server to spawn `claude --resume`
521
+ // (resume_tui_session); the follow-up session_switched then attaches xterm.
522
+ var resumeBarEl = null;
523
+ var resumeBarSessionId = null;
524
+
525
+ function ensureResumeBar() {
526
+ if (resumeBarEl) return resumeBarEl;
527
+ resumeBarEl = document.createElement("div");
528
+ resumeBarEl.id = "tui-resume-bar";
529
+ resumeBarEl.innerHTML =
530
+ '<button type="button" class="tui-resume-btn">' +
531
+ iconHtml("play") + '<span>Resume in terminal</span></button>' +
532
+ '<span class="tui-resume-hint">Read-only history · resume to continue in the terminal</span>';
533
+ resumeBarEl.querySelector(".tui-resume-btn").addEventListener("click", function () {
534
+ if (resumeBarSessionId == null) return;
535
+ var ws = getWs();
536
+ if (ws && ws.readyState === 1) {
537
+ ws.send(JSON.stringify({ type: "resume_tui_session", id: resumeBarSessionId }));
538
+ }
539
+ });
540
+ var inputArea = document.getElementById("input-area");
541
+ if (inputArea && inputArea.parentNode) {
542
+ inputArea.parentNode.insertBefore(resumeBarEl, inputArea.nextSibling);
543
+ } else {
544
+ document.body.appendChild(resumeBarEl);
545
+ }
546
+ return resumeBarEl;
547
+ }
548
+
549
+ // Toggle the read-only/Resume presentation. `active` true hides the composer
550
+ // and shows the Resume bar for `sessionId`; false restores normal chrome.
551
+ export function setTuiSuspendedView(active, sessionId) {
552
+ if (active) {
553
+ ensureResumeBar();
554
+ resumeBarSessionId = sessionId;
555
+ if (document.body) document.body.classList.add("tui-suspended");
556
+ refreshIcons();
557
+ } else {
558
+ resumeBarSessionId = null;
559
+ if (document.body) document.body.classList.remove("tui-suspended");
560
+ }
561
+ }
562
+
563
+ // Bind IME composition handlers to xterm's helper textarea so CJK / mobile
564
+ // composed input is sent as whole characters instead of per-keystroke jamo.
565
+ function bindImeComposition(term) {
566
+ var ta = term && term.textarea;
567
+ if (!ta) return;
568
+ ta.addEventListener("compositionstart", function () {
569
+ imeComposing = true;
570
+ });
571
+ ta.addEventListener("compositionend", function (e) {
572
+ imeComposing = false;
573
+ var composed = (e && e.data) || "";
574
+ if (!composed) return;
575
+ imeLastComposed = composed;
576
+ imeLastComposedAt = Date.now();
577
+ sendTermInput(composed);
578
+ });
372
579
  }
373
580
 
374
581
  function teardownXterm() {
@@ -381,6 +588,9 @@ function teardownXterm() {
381
588
  xterm = null;
382
589
  }
383
590
  fitAddon = null;
591
+ imeComposing = false;
592
+ imeLastComposed = "";
593
+ imeLastComposedAt = 0;
384
594
  }
385
595
 
386
596
  export function attachTuiView(terminalId) {
@@ -390,8 +600,9 @@ export function attachTuiView(terminalId) {
390
600
  if (hostEl) hostEl.style.display = "flex";
391
601
  hideGuiChrome(true);
392
602
  showHeaderTuiFont();
603
+ showHeaderTuiClose();
393
604
  fitNow();
394
- try { xterm.focus(); } catch (e) {}
605
+ focusGuiComposer();
395
606
  return;
396
607
  }
397
608
  // Switching to a different TUI terminal: tear down the old one cleanly.
@@ -402,6 +613,7 @@ export function attachTuiView(terminalId) {
402
613
  hostEl.style.display = "flex";
403
614
  hideGuiChrome(true);
404
615
  showHeaderTuiFont();
616
+ showHeaderTuiClose();
405
617
  syncHostBounds();
406
618
 
407
619
  currentTermId = terminalId;
@@ -418,7 +630,7 @@ export function attachTuiView(terminalId) {
418
630
  // First fit pass; defer a second pass for layout to settle.
419
631
  fitNow();
420
632
  setTimeout(fitNow, 50);
421
- try { xterm.focus(); } catch (e) {}
633
+ focusGuiComposer();
422
634
 
423
635
  if (!resizeObserver && typeof ResizeObserver !== "undefined") {
424
636
  // Watch the chat-content area, not the host: the host's size is
@@ -449,9 +661,11 @@ export function detachTuiView() {
449
661
  }
450
662
  currentTermId = null;
451
663
  teardownXterm();
664
+ if (keyToolbar) keyToolbar.reset();
452
665
  if (hostEl) hostEl.style.display = "none";
453
666
  hideGuiChrome(false);
454
667
  hideHeaderTuiFont();
668
+ hideHeaderTuiClose();
455
669
  }
456
670
 
457
671
  // Route a term_output frame to the embedded xterm if it belongs to the
@@ -709,7 +709,8 @@ function renderMobileSessionsInto(container) {
709
709
  claudeBtn.innerHTML = '<img src="/claude-code-avatar.png" class="mobile-session-new-icon" alt=""><span>Claude</span>';
710
710
  claudeBtn.addEventListener("click", function () {
711
711
  if (getWs() && store.get('connected')) {
712
- getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: "tui" }));
712
+ // No explicit mode: the server applies the user's claudeOpenMode pref.
713
+ getWs().send(JSON.stringify({ type: "new_session", vendor: "claude" }));
713
714
  }
714
715
  closeMobileSheet();
715
716
  });