clay-server 2.40.0-beta.3 → 2.40.0
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/claude-hook-installer.js +24 -76
- package/lib/cli-sessions.js +80 -45
- package/lib/project-connection.js +9 -1
- package/lib/project-sessions.js +132 -55
- package/lib/project.js +1 -0
- package/lib/public/css/filebrowser.css +2 -2
- package/lib/public/css/input.css +12 -0
- package/lib/public/css/menus.css +23 -0
- package/lib/public/css/sidebar.css +0 -85
- package/lib/public/css/tui-attention.css +41 -0
- package/lib/public/index.html +2 -1
- package/lib/public/modules/app-messages.js +4 -1
- package/lib/public/modules/input.js +43 -5
- package/lib/public/modules/session-tui-view.js +224 -10
- package/lib/public/modules/sidebar-mobile.js +2 -1
- package/lib/public/modules/sidebar-sessions.js +11 -83
- package/lib/public/modules/terminal-toolbar.js +129 -0
- package/lib/public/modules/terminal.js +15 -99
- package/lib/safe-bash-commands.js +171 -0
- package/lib/sdk-bridge.js +6 -51
- package/lib/sessions.js +1 -1
- package/lib/terminal-manager.js +44 -0
- package/lib/ws-schema.js +2 -0
- package/package.json +1 -1
|
@@ -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. */
|
package/lib/public/index.html
CHANGED
|
@@ -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): " +
|
|
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 ||
|
|
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: " +
|
|
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: " +
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
490
|
+
ws.send(JSON.stringify({ type: "suspend_tui_session", id: sid }));
|
|
369
491
|
}
|
|
370
492
|
});
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|