codex-autorunner 0.1.0__py3-none-any.whl
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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,3535 @@
|
|
|
1
|
+
import { api, flash, buildWsUrl, getAuthToken, isMobileViewport } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
function base64UrlEncode(value) {
|
|
4
|
+
if (!value) return null;
|
|
5
|
+
try {
|
|
6
|
+
const bytes = new TextEncoder().encode(value);
|
|
7
|
+
let binary = "";
|
|
8
|
+
bytes.forEach((b) => {
|
|
9
|
+
binary += String.fromCharCode(b);
|
|
10
|
+
});
|
|
11
|
+
const base64 = btoa(binary);
|
|
12
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
13
|
+
} catch (_err) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
import { CONSTANTS } from "./constants.js";
|
|
18
|
+
import { initVoiceInput } from "./voice.js";
|
|
19
|
+
import { publish, subscribe } from "./bus.js";
|
|
20
|
+
import { REPO_ID, BASE_PATH } from "./env.js";
|
|
21
|
+
|
|
22
|
+
const textEncoder = new TextEncoder();
|
|
23
|
+
const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
|
24
|
+
const ALT_SCREEN_ENTER_BYTES = textEncoder.encode(ALT_SCREEN_ENTER);
|
|
25
|
+
const ALT_SCREEN_ENTER_SEQUENCES = [
|
|
26
|
+
ALT_SCREEN_ENTER,
|
|
27
|
+
"\x1b[?47h",
|
|
28
|
+
"\x1b[?1047h",
|
|
29
|
+
];
|
|
30
|
+
const ALT_SCREEN_ENTER_MAX_LEN = ALT_SCREEN_ENTER_SEQUENCES.reduce(
|
|
31
|
+
(max, seq) => Math.max(max, seq.length),
|
|
32
|
+
0
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const TEXT_INPUT_STORAGE_KEYS = Object.freeze({
|
|
36
|
+
enabled: "codex_terminal_text_input_enabled",
|
|
37
|
+
draft: "codex_terminal_text_input_draft",
|
|
38
|
+
pending: "codex_terminal_text_input_pending",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const TEXT_INPUT_SIZE_LIMITS = Object.freeze({
|
|
42
|
+
warnBytes: 100 * 1024,
|
|
43
|
+
chunkBytes: 256 * 1024,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const TEXT_INPUT_HOOK_STORAGE_PREFIX = "codex_terminal_text_input_hook:";
|
|
47
|
+
|
|
48
|
+
const XTERM_COLOR_MODE_DEFAULT = 0;
|
|
49
|
+
const XTERM_COLOR_MODE_PALETTE_16 = 0x01000000;
|
|
50
|
+
const XTERM_COLOR_MODE_PALETTE_256 = 0x02000000;
|
|
51
|
+
const XTERM_COLOR_MODE_RGB = 0x03000000;
|
|
52
|
+
|
|
53
|
+
const CAR_CONTEXT_HOOK_ID = "car_context";
|
|
54
|
+
const GITHUB_CONTEXT_HOOK_ID = "github_context";
|
|
55
|
+
const CAR_CONTEXT_KEYWORDS = [
|
|
56
|
+
"car",
|
|
57
|
+
"codex",
|
|
58
|
+
"todo",
|
|
59
|
+
"progress",
|
|
60
|
+
"opinions",
|
|
61
|
+
"spec",
|
|
62
|
+
"summary",
|
|
63
|
+
"autorunner",
|
|
64
|
+
"work docs",
|
|
65
|
+
];
|
|
66
|
+
const GITHUB_LINK_RE =
|
|
67
|
+
/https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:issues|pull)\/\d+(?:[/?#][^\s]*)?/i;
|
|
68
|
+
const CAR_CONTEXT_HINT_TEXT =
|
|
69
|
+
"Context: read .codex-autorunner/ABOUT_CAR.md for repo-specific rules.";
|
|
70
|
+
const CAR_CONTEXT_HINT = wrapInjectedContext(CAR_CONTEXT_HINT_TEXT);
|
|
71
|
+
const VOICE_TRANSCRIPT_DISCLAIMER_TEXT =
|
|
72
|
+
CONSTANTS.PROMPTS?.VOICE_TRANSCRIPT_DISCLAIMER ||
|
|
73
|
+
"Note: transcribed from user voice. If confusing or possibly inaccurate and you cannot infer the intention please clarify before proceeding.";
|
|
74
|
+
const INJECTED_CONTEXT_TAG_RE = /<injected context>/i;
|
|
75
|
+
|
|
76
|
+
function wrapInjectedContext(text) {
|
|
77
|
+
return `<injected context>\n${text}\n</injected context>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function wrapInjectedContextIfNeeded(text) {
|
|
81
|
+
if (!text) return text;
|
|
82
|
+
return INJECTED_CONTEXT_TAG_RE.test(text) ? text : wrapInjectedContext(text);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const LEGACY_SESSION_STORAGE_KEY = "codex_terminal_session_id";
|
|
86
|
+
const SESSION_STORAGE_PREFIX = "codex_terminal_session_id:";
|
|
87
|
+
const SESSION_STORAGE_TS_PREFIX = "codex_terminal_session_ts:";
|
|
88
|
+
|
|
89
|
+
const TOUCH_OVERRIDE = (() => {
|
|
90
|
+
try {
|
|
91
|
+
const params = new URLSearchParams(window.location.search);
|
|
92
|
+
const truthy = new Set(["1", "true", "yes", "on"]);
|
|
93
|
+
const falsy = new Set(["0", "false", "no", "off"]);
|
|
94
|
+
|
|
95
|
+
const touchParam = params.get("force_touch") ?? params.get("touch");
|
|
96
|
+
if (touchParam !== null) {
|
|
97
|
+
const value = String(touchParam).toLowerCase();
|
|
98
|
+
if (truthy.has(value)) return true;
|
|
99
|
+
if (falsy.has(value)) return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const desktopParam = params.get("force_desktop") ?? params.get("desktop");
|
|
103
|
+
if (desktopParam !== null) {
|
|
104
|
+
const value = String(desktopParam).toLowerCase();
|
|
105
|
+
if (truthy.has(value)) return false;
|
|
106
|
+
if (falsy.has(value)) return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
} catch (_err) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
const TERMINAL_DEBUG = (() => {
|
|
116
|
+
try {
|
|
117
|
+
const params = new URLSearchParams(window.location.search);
|
|
118
|
+
const truthy = new Set(["1", "true", "yes", "on"]);
|
|
119
|
+
const falsy = new Set(["0", "false", "no", "off"]);
|
|
120
|
+
const param = params.get("terminal_debug") ?? params.get("debug_terminal");
|
|
121
|
+
if (param !== null) {
|
|
122
|
+
const value = String(param).toLowerCase();
|
|
123
|
+
if (truthy.has(value)) return true;
|
|
124
|
+
if (falsy.has(value)) return false;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const stored = localStorage.getItem("codex_terminal_debug");
|
|
128
|
+
if (stored !== null) {
|
|
129
|
+
const value = String(stored).toLowerCase();
|
|
130
|
+
if (truthy.has(value)) return true;
|
|
131
|
+
if (falsy.has(value)) return false;
|
|
132
|
+
}
|
|
133
|
+
} catch (_err) {
|
|
134
|
+
// ignore storage errors
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
} catch (_err) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* TerminalManager encapsulates all terminal state and logic including:
|
|
144
|
+
* - xterm.js terminal instance and fit addon
|
|
145
|
+
* - WebSocket connection handling with reconnection
|
|
146
|
+
* - Voice input integration
|
|
147
|
+
* - Text input panel
|
|
148
|
+
* - Mobile controls
|
|
149
|
+
*/
|
|
150
|
+
export class TerminalManager {
|
|
151
|
+
constructor() {
|
|
152
|
+
// Core terminal state
|
|
153
|
+
this.term = null;
|
|
154
|
+
this.fitAddon = null;
|
|
155
|
+
this.socket = null;
|
|
156
|
+
this.inputDisposable = null;
|
|
157
|
+
this.wheelScrollInstalled = false;
|
|
158
|
+
this.wheelScrollRemainder = 0;
|
|
159
|
+
this.touchScrollInstalled = false;
|
|
160
|
+
this.touchScrollLastY = null;
|
|
161
|
+
this.touchScrollRemainder = 0;
|
|
162
|
+
|
|
163
|
+
// Connection state
|
|
164
|
+
this.intentionalDisconnect = false;
|
|
165
|
+
this.reconnectTimer = null;
|
|
166
|
+
this.reconnectAttempts = 0;
|
|
167
|
+
this.lastConnectMode = null;
|
|
168
|
+
this.suppressNextNotFoundFlash = false;
|
|
169
|
+
this.currentSessionId = null;
|
|
170
|
+
this.statusBase = "Disconnected";
|
|
171
|
+
this.terminalIdleTimeoutSeconds = null;
|
|
172
|
+
this.sessionNotFound = false;
|
|
173
|
+
this.terminalDebug = TERMINAL_DEBUG;
|
|
174
|
+
this.replayChunkCount = 0;
|
|
175
|
+
this.replayByteCount = 0;
|
|
176
|
+
this.liveChunkCount = 0;
|
|
177
|
+
this.liveByteCount = 0;
|
|
178
|
+
this.lastAltBufferActive = null;
|
|
179
|
+
this.lastAltScrollbackSize = 0;
|
|
180
|
+
|
|
181
|
+
// UI element references
|
|
182
|
+
this.statusEl = null;
|
|
183
|
+
this.overlayEl = null;
|
|
184
|
+
this.connectBtn = null;
|
|
185
|
+
this.disconnectBtn = null;
|
|
186
|
+
this.resumeBtn = null;
|
|
187
|
+
this.jumpBottomBtn = null;
|
|
188
|
+
|
|
189
|
+
// Voice state
|
|
190
|
+
this.voiceBtn = null;
|
|
191
|
+
this.voiceStatus = null;
|
|
192
|
+
this.voiceController = null;
|
|
193
|
+
this.voiceKeyActive = false;
|
|
194
|
+
this.mobileVoiceBtn = null;
|
|
195
|
+
this.mobileVoiceController = null;
|
|
196
|
+
|
|
197
|
+
// Resize state
|
|
198
|
+
this.resizeRaf = null;
|
|
199
|
+
|
|
200
|
+
// Text input panel state
|
|
201
|
+
this.terminalSectionEl = null;
|
|
202
|
+
this.textInputToggleBtn = null;
|
|
203
|
+
this.textInputPanelEl = null;
|
|
204
|
+
this.textInputTextareaEl = null;
|
|
205
|
+
this.textInputSendBtn = null;
|
|
206
|
+
this.textInputImageBtn = null;
|
|
207
|
+
this.textInputImageInputEl = null;
|
|
208
|
+
this.textInputEnabled = false;
|
|
209
|
+
this.textInputPending = null;
|
|
210
|
+
this.textInputPendingChunks = null;
|
|
211
|
+
this.textInputSendBtnLabel = null;
|
|
212
|
+
this.textInputHintBase = null;
|
|
213
|
+
this.textInputHooks = [];
|
|
214
|
+
this.textInputSelection = { start: null, end: null };
|
|
215
|
+
this.textInputHookInFlight = false;
|
|
216
|
+
|
|
217
|
+
// Mobile controls state
|
|
218
|
+
this.mobileControlsEl = null;
|
|
219
|
+
this.ctrlActive = false;
|
|
220
|
+
this.altActive = false;
|
|
221
|
+
this.baseViewportHeight = window.innerHeight;
|
|
222
|
+
this.suppressNextSendClick = false;
|
|
223
|
+
this.lastSendTapAt = 0;
|
|
224
|
+
this.textInputWasFocused = false;
|
|
225
|
+
this.deferScrollRestore = false;
|
|
226
|
+
this.savedViewportY = null;
|
|
227
|
+
this.savedAtBottom = null;
|
|
228
|
+
this.mobileViewEl = null;
|
|
229
|
+
// Mobile compose view: a read-only, scrollable mirror of the terminal buffer.
|
|
230
|
+
// Purpose: when the text input is focused on touch devices, allow easy browsing
|
|
231
|
+
// without fighting the on-screen keyboard or accidentally sending keystrokes to the TUI.
|
|
232
|
+
this.mobileViewActive = false;
|
|
233
|
+
this.mobileViewScrollTop = null;
|
|
234
|
+
this.mobileViewAtBottom = true;
|
|
235
|
+
this.mobileViewRaf = null;
|
|
236
|
+
this.mobileViewDirty = false;
|
|
237
|
+
this.mobileViewSuppressAtBottomRecalc = false;
|
|
238
|
+
|
|
239
|
+
this.transcriptLines = [];
|
|
240
|
+
this.transcriptLineCells = [];
|
|
241
|
+
this.transcriptCursor = 0;
|
|
242
|
+
this.transcriptMaxLines = 2000;
|
|
243
|
+
this.transcriptHydrated = false;
|
|
244
|
+
this.transcriptAnsiState = {
|
|
245
|
+
mode: "text",
|
|
246
|
+
oscEsc: false,
|
|
247
|
+
csiParams: "",
|
|
248
|
+
fg: null,
|
|
249
|
+
bg: null,
|
|
250
|
+
fgRgb: null,
|
|
251
|
+
bgRgb: null,
|
|
252
|
+
bold: false,
|
|
253
|
+
className: "",
|
|
254
|
+
style: "",
|
|
255
|
+
};
|
|
256
|
+
this.transcriptPersistTimer = null;
|
|
257
|
+
this.transcriptDecoder = new TextDecoder();
|
|
258
|
+
this.awaitingReplayEnd = false;
|
|
259
|
+
this.replayBuffer = null;
|
|
260
|
+
this.replayPrelude = null;
|
|
261
|
+
this.pendingReplayPrelude = null;
|
|
262
|
+
this.clearTranscriptOnFirstLiveData = false;
|
|
263
|
+
this._resetTerminalDebugCounters();
|
|
264
|
+
this.lastAltBufferActive = null;
|
|
265
|
+
this.lastAltScrollbackSize = 0;
|
|
266
|
+
this.transcriptResetForConnect = false;
|
|
267
|
+
|
|
268
|
+
this._registerTextInputHook(this._buildCarContextHook());
|
|
269
|
+
this._registerTextInputHook(this._buildGithubContextHook());
|
|
270
|
+
|
|
271
|
+
// Bind methods that are used as callbacks
|
|
272
|
+
this._handleResize = this._handleResize.bind(this);
|
|
273
|
+
this._handleVoiceHotkeyDown = this._handleVoiceHotkeyDown.bind(this);
|
|
274
|
+
this._handleVoiceHotkeyUp = this._handleVoiceHotkeyUp.bind(this);
|
|
275
|
+
this._scheduleResizeAfterLayout = this._scheduleResizeAfterLayout.bind(this);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check if device has touch capability
|
|
280
|
+
*/
|
|
281
|
+
isTouchDevice() {
|
|
282
|
+
if (TOUCH_OVERRIDE !== null) return TOUCH_OVERRIDE;
|
|
283
|
+
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Initialize the terminal manager and all sub-components
|
|
288
|
+
*/
|
|
289
|
+
init() {
|
|
290
|
+
this.statusEl = document.getElementById("terminal-status");
|
|
291
|
+
this.overlayEl = document.getElementById("terminal-overlay");
|
|
292
|
+
this.connectBtn = document.getElementById("terminal-connect");
|
|
293
|
+
this.disconnectBtn = document.getElementById("terminal-disconnect");
|
|
294
|
+
this.resumeBtn = document.getElementById("terminal-resume");
|
|
295
|
+
this.jumpBottomBtn = document.getElementById("terminal-jump-bottom");
|
|
296
|
+
|
|
297
|
+
if (!this.statusEl || !this.connectBtn || !this.disconnectBtn || !this.resumeBtn) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
this.connectBtn.addEventListener("click", () => this.connect({ mode: "new" }));
|
|
302
|
+
this.resumeBtn.addEventListener("click", () => this.connect({ mode: "resume" }));
|
|
303
|
+
this.disconnectBtn.addEventListener("click", () => this.disconnect());
|
|
304
|
+
this.jumpBottomBtn?.addEventListener("click", () => {
|
|
305
|
+
this.term?.scrollToBottom();
|
|
306
|
+
this._updateJumpBottomVisibility();
|
|
307
|
+
if (!this.isTouchDevice()) {
|
|
308
|
+
this.term?.focus();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
this._updateButtons(false);
|
|
312
|
+
this._setStatus("Disconnected");
|
|
313
|
+
this._restoreTranscript();
|
|
314
|
+
|
|
315
|
+
window.addEventListener("resize", this._handleResize);
|
|
316
|
+
if (window.visualViewport) {
|
|
317
|
+
window.visualViewport.addEventListener("resize", this._scheduleResizeAfterLayout);
|
|
318
|
+
window.visualViewport.addEventListener("scroll", this._scheduleResizeAfterLayout);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Initialize sub-components
|
|
322
|
+
this._initMobileControls();
|
|
323
|
+
this._initTerminalVoice();
|
|
324
|
+
this._initTextInputPanel();
|
|
325
|
+
|
|
326
|
+
subscribe("state:update", (state) => {
|
|
327
|
+
if (
|
|
328
|
+
state &&
|
|
329
|
+
Object.prototype.hasOwnProperty.call(state, "terminal_idle_timeout_seconds")
|
|
330
|
+
) {
|
|
331
|
+
this.terminalIdleTimeoutSeconds = state.terminal_idle_timeout_seconds;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
if (this.terminalIdleTimeoutSeconds === null) {
|
|
335
|
+
this._loadTerminalIdleTimeout().catch(() => {});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Auto-connect if session ID exists
|
|
339
|
+
if (this._getSavedSessionId()) {
|
|
340
|
+
this.connect({ mode: "attach" });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Set terminal status message
|
|
346
|
+
*/
|
|
347
|
+
_setStatus(message) {
|
|
348
|
+
this.statusBase = message;
|
|
349
|
+
this._renderStatus();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_logTerminalDebug(message, details = null) {
|
|
353
|
+
if (!this.terminalDebug) return;
|
|
354
|
+
const prefix = "[terminal-debug]";
|
|
355
|
+
if (details) {
|
|
356
|
+
console.info(prefix, message, details);
|
|
357
|
+
} else {
|
|
358
|
+
console.info(prefix, message);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_logBufferSnapshot(reason) {
|
|
363
|
+
if (!this.terminalDebug || !this.term) return;
|
|
364
|
+
const buffer = this.term.buffer?.active;
|
|
365
|
+
this._logTerminalDebug("buffer snapshot", {
|
|
366
|
+
reason,
|
|
367
|
+
alt: this._isAltBufferActive(),
|
|
368
|
+
type: buffer && typeof buffer.type === "string" ? buffer.type : null,
|
|
369
|
+
length: buffer ? buffer.length : null,
|
|
370
|
+
baseY: buffer ? buffer.baseY : null,
|
|
371
|
+
viewportY: buffer ? buffer.viewportY : null,
|
|
372
|
+
cursorY: buffer ? buffer.cursorY : null,
|
|
373
|
+
rows: this.term.rows,
|
|
374
|
+
cols: this.term.cols,
|
|
375
|
+
scrollback:
|
|
376
|
+
typeof this.term.options?.scrollback === "number"
|
|
377
|
+
? this.term.options.scrollback
|
|
378
|
+
: null,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
_resetTerminalDebugCounters() {
|
|
383
|
+
this.replayChunkCount = 0;
|
|
384
|
+
this.replayByteCount = 0;
|
|
385
|
+
this.liveChunkCount = 0;
|
|
386
|
+
this.liveByteCount = 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
_renderStatus() {
|
|
390
|
+
if (!this.statusEl) return;
|
|
391
|
+
const sessionId = this.currentSessionId;
|
|
392
|
+
if (!sessionId) {
|
|
393
|
+
this.statusEl.textContent = this.statusBase;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const repoLabel = this._getRepoLabel();
|
|
397
|
+
const suffix = repoLabel
|
|
398
|
+
? ` (session ${sessionId} · repo ${repoLabel})`
|
|
399
|
+
: ` (session ${sessionId})`;
|
|
400
|
+
this.statusEl.textContent = `${this.statusBase}${suffix}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
_getRepoLabel() {
|
|
404
|
+
if (REPO_ID) return REPO_ID;
|
|
405
|
+
if (BASE_PATH) return BASE_PATH;
|
|
406
|
+
return "repo";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
_getRepoStorageKey() {
|
|
410
|
+
return REPO_ID || BASE_PATH || window.location.pathname || "default";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
_getTextInputHookKey(hookId) {
|
|
414
|
+
const sessionId = this.currentSessionId || this._getSavedSessionId();
|
|
415
|
+
const scope = sessionId
|
|
416
|
+
? `session:${sessionId}`
|
|
417
|
+
: `pending:${this._getRepoStorageKey()}`;
|
|
418
|
+
return `${TEXT_INPUT_HOOK_STORAGE_PREFIX}${hookId}:${scope}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
_migrateTextInputHookSession(hookId, sessionId) {
|
|
422
|
+
if (!sessionId) return;
|
|
423
|
+
const pendingKey = `${TEXT_INPUT_HOOK_STORAGE_PREFIX}${hookId}:pending:${this._getRepoStorageKey()}`;
|
|
424
|
+
const sessionKey = `${TEXT_INPUT_HOOK_STORAGE_PREFIX}${hookId}:session:${sessionId}`;
|
|
425
|
+
try {
|
|
426
|
+
if (sessionStorage.getItem(pendingKey) === "1") {
|
|
427
|
+
sessionStorage.setItem(sessionKey, "1");
|
|
428
|
+
sessionStorage.removeItem(pendingKey);
|
|
429
|
+
}
|
|
430
|
+
} catch (_err) {
|
|
431
|
+
// ignore
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
_hasTextInputHookFired(hookId) {
|
|
436
|
+
try {
|
|
437
|
+
return sessionStorage.getItem(this._getTextInputHookKey(hookId)) === "1";
|
|
438
|
+
} catch (_err) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_markTextInputHookFired(hookId) {
|
|
444
|
+
try {
|
|
445
|
+
sessionStorage.setItem(this._getTextInputHookKey(hookId), "1");
|
|
446
|
+
} catch (_err) {
|
|
447
|
+
// ignore
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_registerTextInputHook(hook) {
|
|
452
|
+
if (!hook || typeof hook.apply !== "function") return;
|
|
453
|
+
this.textInputHooks.push(hook);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_applyTextInputHooks(text) {
|
|
457
|
+
let next = text;
|
|
458
|
+
for (const hook of this.textInputHooks) {
|
|
459
|
+
try {
|
|
460
|
+
const result = hook.apply({ text: next, manager: this });
|
|
461
|
+
if (!result) continue;
|
|
462
|
+
if (typeof result === "string") {
|
|
463
|
+
next = result;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (result && typeof result.text === "string") {
|
|
467
|
+
next = result.text;
|
|
468
|
+
}
|
|
469
|
+
if (result && result.stop) break;
|
|
470
|
+
} catch (_err) {
|
|
471
|
+
// ignore hook failures
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return next;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async _applyTextInputHooksAsync(text) {
|
|
478
|
+
let next = text;
|
|
479
|
+
for (const hook of this.textInputHooks) {
|
|
480
|
+
try {
|
|
481
|
+
let result = hook.apply({ text: next, manager: this });
|
|
482
|
+
if (result && typeof result.then === "function") {
|
|
483
|
+
result = await result;
|
|
484
|
+
}
|
|
485
|
+
if (!result) continue;
|
|
486
|
+
if (typeof result === "string") {
|
|
487
|
+
next = result;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (result && typeof result.text === "string") {
|
|
491
|
+
next = result.text;
|
|
492
|
+
}
|
|
493
|
+
if (result && result.stop) break;
|
|
494
|
+
} catch (_err) {
|
|
495
|
+
// ignore hook failures
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return next;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
_buildCarContextHook() {
|
|
502
|
+
return {
|
|
503
|
+
id: CAR_CONTEXT_HOOK_ID,
|
|
504
|
+
apply: ({ text, manager }) => {
|
|
505
|
+
if (!text || !text.trim()) return null;
|
|
506
|
+
if (manager._hasTextInputHookFired(CAR_CONTEXT_HOOK_ID)) return null;
|
|
507
|
+
|
|
508
|
+
const lowered = text.toLowerCase();
|
|
509
|
+
const hit = CAR_CONTEXT_KEYWORDS.some((kw) => lowered.includes(kw));
|
|
510
|
+
if (!hit) return null;
|
|
511
|
+
if (lowered.includes("about_car.md")) return null;
|
|
512
|
+
if (
|
|
513
|
+
text.includes(CAR_CONTEXT_HINT_TEXT) ||
|
|
514
|
+
text.includes(CAR_CONTEXT_HINT)
|
|
515
|
+
) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
manager._markTextInputHookFired(CAR_CONTEXT_HOOK_ID);
|
|
520
|
+
const injection = wrapInjectedContextIfNeeded(CAR_CONTEXT_HINT);
|
|
521
|
+
const separator = text.endsWith("\n") ? "\n" : "\n\n";
|
|
522
|
+
return { text: `${text}${separator}${injection}` };
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
_buildGithubContextHook() {
|
|
528
|
+
return {
|
|
529
|
+
id: GITHUB_CONTEXT_HOOK_ID,
|
|
530
|
+
apply: async ({ text }) => {
|
|
531
|
+
if (!text || !text.trim()) return null;
|
|
532
|
+
const match = text.match(GITHUB_LINK_RE);
|
|
533
|
+
if (!match) return null;
|
|
534
|
+
try {
|
|
535
|
+
const res = await api("/api/github/context", {
|
|
536
|
+
method: "POST",
|
|
537
|
+
body: { url: match[0] },
|
|
538
|
+
});
|
|
539
|
+
if (!res || !res.injected || !res.hint) return null;
|
|
540
|
+
const injection = wrapInjectedContextIfNeeded(res.hint);
|
|
541
|
+
const separator = text.endsWith("\n") ? "\n" : "\n\n";
|
|
542
|
+
return { text: `${text}${separator}${injection}` };
|
|
543
|
+
} catch (_err) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async _loadTerminalIdleTimeout() {
|
|
551
|
+
try {
|
|
552
|
+
const data = await api(CONSTANTS.API.STATE_ENDPOINT);
|
|
553
|
+
if (
|
|
554
|
+
data &&
|
|
555
|
+
Object.prototype.hasOwnProperty.call(data, "terminal_idle_timeout_seconds")
|
|
556
|
+
) {
|
|
557
|
+
this.terminalIdleTimeoutSeconds = data.terminal_idle_timeout_seconds;
|
|
558
|
+
}
|
|
559
|
+
} catch (_err) {
|
|
560
|
+
// ignore
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
_getSessionStorageKey() {
|
|
565
|
+
return `${SESSION_STORAGE_PREFIX}${this._getRepoStorageKey()}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
_getSessionTimestampKey() {
|
|
569
|
+
return `${SESSION_STORAGE_TS_PREFIX}${this._getRepoStorageKey()}`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
_getSavedSessionTimestamp() {
|
|
573
|
+
const raw = localStorage.getItem(this._getSessionTimestampKey());
|
|
574
|
+
if (!raw) return null;
|
|
575
|
+
const parsed = Number(raw);
|
|
576
|
+
if (!Number.isFinite(parsed)) return null;
|
|
577
|
+
return parsed;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
_setSavedSessionTimestamp(stamp) {
|
|
581
|
+
if (!stamp) return;
|
|
582
|
+
localStorage.setItem(this._getSessionTimestampKey(), String(stamp));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
_clearSavedSessionTimestamp() {
|
|
586
|
+
localStorage.removeItem(this._getSessionTimestampKey());
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
_isSessionStale(lastActiveAt) {
|
|
590
|
+
if (lastActiveAt === null || lastActiveAt === undefined) return false;
|
|
591
|
+
if (
|
|
592
|
+
this.terminalIdleTimeoutSeconds === null ||
|
|
593
|
+
this.terminalIdleTimeoutSeconds === undefined
|
|
594
|
+
) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
if (typeof this.terminalIdleTimeoutSeconds !== "number") return false;
|
|
598
|
+
if (this.terminalIdleTimeoutSeconds <= 0) return false;
|
|
599
|
+
const maxAgeMs = this.terminalIdleTimeoutSeconds * 1000;
|
|
600
|
+
return Date.now() - lastActiveAt > maxAgeMs;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
_getSavedSessionId() {
|
|
604
|
+
const scopedKey = this._getSessionStorageKey();
|
|
605
|
+
const scoped = localStorage.getItem(scopedKey);
|
|
606
|
+
if (scoped) {
|
|
607
|
+
const lastActiveAt = this._getSavedSessionTimestamp();
|
|
608
|
+
if (this._isSessionStale(lastActiveAt)) {
|
|
609
|
+
this._clearSavedSessionId();
|
|
610
|
+
this._clearSavedSessionTimestamp();
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
return scoped;
|
|
614
|
+
}
|
|
615
|
+
const legacy = localStorage.getItem(LEGACY_SESSION_STORAGE_KEY);
|
|
616
|
+
if (!legacy) return null;
|
|
617
|
+
const hasScoped = Object.keys(localStorage).some((key) =>
|
|
618
|
+
key.startsWith(SESSION_STORAGE_PREFIX)
|
|
619
|
+
);
|
|
620
|
+
if (!hasScoped) {
|
|
621
|
+
localStorage.setItem(scopedKey, legacy);
|
|
622
|
+
this._setSavedSessionTimestamp(Date.now());
|
|
623
|
+
localStorage.removeItem(LEGACY_SESSION_STORAGE_KEY);
|
|
624
|
+
return legacy;
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
_setSavedSessionId(sessionId) {
|
|
630
|
+
if (!sessionId) return;
|
|
631
|
+
localStorage.setItem(this._getSessionStorageKey(), sessionId);
|
|
632
|
+
this._setSavedSessionTimestamp(Date.now());
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
_clearSavedSessionId() {
|
|
636
|
+
localStorage.removeItem(this._getSessionStorageKey());
|
|
637
|
+
this._clearSavedSessionTimestamp();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
_markSessionActive() {
|
|
641
|
+
this._setSavedSessionTimestamp(Date.now());
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
_setCurrentSessionId(sessionId) {
|
|
645
|
+
this.currentSessionId = sessionId || null;
|
|
646
|
+
if (this.currentSessionId) {
|
|
647
|
+
this._migrateTextInputHookSession(CAR_CONTEXT_HOOK_ID, this.currentSessionId);
|
|
648
|
+
}
|
|
649
|
+
this._renderStatus();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Get appropriate font size based on screen width
|
|
654
|
+
*/
|
|
655
|
+
_getFontSize() {
|
|
656
|
+
return window.innerWidth < 640 ? 10 : 13;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
_updateJumpBottomVisibility() {
|
|
660
|
+
if (!this.jumpBottomBtn || !this.term) return;
|
|
661
|
+
const buffer = this.term.buffer?.active;
|
|
662
|
+
if (!buffer) {
|
|
663
|
+
this.jumpBottomBtn.classList.add("hidden");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const atBottom = buffer.viewportY >= buffer.baseY;
|
|
667
|
+
this.jumpBottomBtn.classList.toggle("hidden", atBottom);
|
|
668
|
+
if (this.mobileViewActive) {
|
|
669
|
+
this.mobileViewAtBottom = atBottom;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
_captureTerminalScrollState() {
|
|
674
|
+
if (!this.term) return;
|
|
675
|
+
const buffer = this.term.buffer?.active;
|
|
676
|
+
if (!buffer) return;
|
|
677
|
+
this.savedViewportY = buffer.viewportY;
|
|
678
|
+
this.savedAtBottom = buffer.viewportY >= buffer.baseY;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
_restoreTerminalScrollState() {
|
|
682
|
+
if (!this.term) return;
|
|
683
|
+
const buffer = this.term.buffer?.active;
|
|
684
|
+
if (!buffer) return;
|
|
685
|
+
if (this.savedAtBottom) {
|
|
686
|
+
this.term.scrollToBottom();
|
|
687
|
+
} else if (Number.isInteger(this.savedViewportY)) {
|
|
688
|
+
const delta = this.savedViewportY - buffer.viewportY;
|
|
689
|
+
if (delta !== 0) {
|
|
690
|
+
this.term.scrollLines(delta);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
this._updateJumpBottomVisibility();
|
|
694
|
+
this.savedViewportY = null;
|
|
695
|
+
this.savedAtBottom = null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
_scrollToBottomIfNearBottom() {
|
|
699
|
+
if (!this.term) return;
|
|
700
|
+
const buffer = this.term.buffer?.active;
|
|
701
|
+
if (!buffer) return;
|
|
702
|
+
const atBottom = buffer.viewportY >= buffer.baseY - 1;
|
|
703
|
+
if (atBottom) {
|
|
704
|
+
this.term.scrollToBottom();
|
|
705
|
+
this._updateJumpBottomVisibility();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
_resetTerminalDisplay() {
|
|
710
|
+
if (!this.term) return;
|
|
711
|
+
try {
|
|
712
|
+
this.term.reset();
|
|
713
|
+
} catch (_err) {
|
|
714
|
+
try {
|
|
715
|
+
this.term.clear();
|
|
716
|
+
} catch (__err) {
|
|
717
|
+
// ignore
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
_resetTranscript() {
|
|
723
|
+
this.transcriptLines = [];
|
|
724
|
+
this.transcriptLineCells = [];
|
|
725
|
+
this.transcriptCursor = 0;
|
|
726
|
+
this.transcriptHydrated = false;
|
|
727
|
+
this._clearAltScrollbackState();
|
|
728
|
+
this.transcriptAnsiState = {
|
|
729
|
+
mode: "text",
|
|
730
|
+
oscEsc: false,
|
|
731
|
+
csiParams: "",
|
|
732
|
+
fg: null,
|
|
733
|
+
bg: null,
|
|
734
|
+
fgRgb: null,
|
|
735
|
+
bgRgb: null,
|
|
736
|
+
bold: false,
|
|
737
|
+
className: "",
|
|
738
|
+
style: "",
|
|
739
|
+
};
|
|
740
|
+
this.transcriptDecoder = new TextDecoder();
|
|
741
|
+
this._persistTranscript(true);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
_transcriptStorageKey() {
|
|
745
|
+
const scope = REPO_ID || BASE_PATH || "default";
|
|
746
|
+
return `codex_terminal_transcript:${scope}`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
_restoreTranscript() {
|
|
750
|
+
try {
|
|
751
|
+
const key = this._transcriptStorageKey();
|
|
752
|
+
let raw = null;
|
|
753
|
+
let fromSessionStorage = false;
|
|
754
|
+
try {
|
|
755
|
+
raw = localStorage.getItem(key);
|
|
756
|
+
} catch (_err) {
|
|
757
|
+
raw = null;
|
|
758
|
+
}
|
|
759
|
+
if (!raw) {
|
|
760
|
+
try {
|
|
761
|
+
raw = sessionStorage.getItem(key);
|
|
762
|
+
fromSessionStorage = Boolean(raw);
|
|
763
|
+
} catch (_err) {
|
|
764
|
+
raw = null;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (!raw) return;
|
|
768
|
+
const parsed = JSON.parse(raw);
|
|
769
|
+
if (Array.isArray(parsed?.lines)) {
|
|
770
|
+
this.transcriptLines = parsed.lines
|
|
771
|
+
.map((line) => this._segmentsToCells(line))
|
|
772
|
+
.filter(Boolean);
|
|
773
|
+
}
|
|
774
|
+
if (Array.isArray(parsed?.line)) {
|
|
775
|
+
this.transcriptLineCells = this._segmentsToCells(parsed.line) || [];
|
|
776
|
+
}
|
|
777
|
+
if (Number.isInteger(parsed?.cursor)) {
|
|
778
|
+
this.transcriptCursor = Math.max(0, parsed.cursor);
|
|
779
|
+
}
|
|
780
|
+
if (fromSessionStorage) {
|
|
781
|
+
try {
|
|
782
|
+
localStorage.setItem(key, raw);
|
|
783
|
+
} catch (_err) {
|
|
784
|
+
// ignore storage errors
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
} catch (_err) {
|
|
788
|
+
// ignore restore errors
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
_persistTranscript(clear = false) {
|
|
793
|
+
try {
|
|
794
|
+
const key = this._transcriptStorageKey();
|
|
795
|
+
if (clear) {
|
|
796
|
+
try {
|
|
797
|
+
localStorage.removeItem(key);
|
|
798
|
+
} catch (_err) {
|
|
799
|
+
// ignore storage errors
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
sessionStorage.removeItem(key);
|
|
803
|
+
} catch (_err) {
|
|
804
|
+
// ignore storage errors
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const payload = JSON.stringify({
|
|
809
|
+
lines: this.transcriptLines.map((line) => this._cellsToSegments(line)),
|
|
810
|
+
line: this._cellsToSegments(this.transcriptLineCells),
|
|
811
|
+
cursor: this.transcriptCursor,
|
|
812
|
+
});
|
|
813
|
+
try {
|
|
814
|
+
localStorage.setItem(key, payload);
|
|
815
|
+
return;
|
|
816
|
+
} catch (_err) {
|
|
817
|
+
// ignore storage errors
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
sessionStorage.setItem(key, payload);
|
|
821
|
+
} catch (_err) {
|
|
822
|
+
// ignore storage errors
|
|
823
|
+
}
|
|
824
|
+
} catch (_err) {
|
|
825
|
+
// ignore storage errors
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
_persistTranscriptSoon() {
|
|
830
|
+
if (this.transcriptPersistTimer) return;
|
|
831
|
+
this.transcriptPersistTimer = setTimeout(() => {
|
|
832
|
+
this.transcriptPersistTimer = null;
|
|
833
|
+
this._persistTranscript(false);
|
|
834
|
+
}, 500);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
_getTranscriptLines() {
|
|
838
|
+
const lines = this.transcriptLines.slice();
|
|
839
|
+
if (this.transcriptLineCells.length) {
|
|
840
|
+
lines.push(this.transcriptLineCells);
|
|
841
|
+
}
|
|
842
|
+
return lines;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
_bufferLineToText(line) {
|
|
846
|
+
if (!line) return "";
|
|
847
|
+
if (typeof line.translateToString === "function") {
|
|
848
|
+
return line.translateToString(true);
|
|
849
|
+
}
|
|
850
|
+
if (typeof line.toString === "function") {
|
|
851
|
+
return line.toString();
|
|
852
|
+
}
|
|
853
|
+
return "";
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
_isAltScreenEnterChunk(chunk) {
|
|
857
|
+
if (!chunk || chunk.length !== ALT_SCREEN_ENTER_BYTES.length) return false;
|
|
858
|
+
for (let idx = 0; idx < ALT_SCREEN_ENTER_BYTES.length; idx++) {
|
|
859
|
+
if (chunk[idx] !== ALT_SCREEN_ENTER_BYTES[idx]) return false;
|
|
860
|
+
}
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
_replayHasAltScreenEnter(chunks) {
|
|
865
|
+
if (!Array.isArray(chunks) || chunks.length === 0) return false;
|
|
866
|
+
const decoder = new TextDecoder();
|
|
867
|
+
const maxTail = Math.max(ALT_SCREEN_ENTER_MAX_LEN - 1, 0);
|
|
868
|
+
let tail = "";
|
|
869
|
+
for (const chunk of chunks) {
|
|
870
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
871
|
+
if (!text) continue;
|
|
872
|
+
const combined = tail + text;
|
|
873
|
+
for (const seq of ALT_SCREEN_ENTER_SEQUENCES) {
|
|
874
|
+
if (combined.includes(seq)) return true;
|
|
875
|
+
}
|
|
876
|
+
tail = maxTail ? combined.slice(-maxTail) : "";
|
|
877
|
+
}
|
|
878
|
+
if (!tail) return false;
|
|
879
|
+
return ALT_SCREEN_ENTER_SEQUENCES.some((seq) => tail.includes(seq));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
_applyReplayPrelude(chunk) {
|
|
883
|
+
if (!chunk || !this.term) return;
|
|
884
|
+
this._appendTranscriptChunk(chunk);
|
|
885
|
+
this._scheduleMobileViewRender();
|
|
886
|
+
this.term.write(chunk);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
_getBufferSnapshot() {
|
|
890
|
+
if (!this.term?.buffer?.active) return null;
|
|
891
|
+
const bufferNamespace = this.term.buffer;
|
|
892
|
+
const buffer = bufferNamespace.active;
|
|
893
|
+
const lineCount =
|
|
894
|
+
Number.isInteger(buffer.length) ? buffer.length : buffer.lines?.length;
|
|
895
|
+
if (!Number.isInteger(lineCount)) return null;
|
|
896
|
+
const start = Math.max(0, lineCount - this.transcriptMaxLines);
|
|
897
|
+
const lines = [];
|
|
898
|
+
for (let idx = start; idx < lineCount; idx++) {
|
|
899
|
+
let line = null;
|
|
900
|
+
if (typeof buffer.getLine === "function") {
|
|
901
|
+
line = buffer.getLine(idx);
|
|
902
|
+
} else if (typeof buffer.lines?.get === "function") {
|
|
903
|
+
line = buffer.lines.get(idx);
|
|
904
|
+
}
|
|
905
|
+
lines.push(line);
|
|
906
|
+
}
|
|
907
|
+
const cols = Number.isInteger(buffer.cols) ? buffer.cols : this.term.cols;
|
|
908
|
+
return { bufferNamespace, buffer, lines, cols };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
_snapshotBufferLines(bufferSnapshot) {
|
|
912
|
+
if (!bufferSnapshot) return null;
|
|
913
|
+
const cols = bufferSnapshot.cols ?? this.term?.cols;
|
|
914
|
+
const plain = [];
|
|
915
|
+
const html = [];
|
|
916
|
+
for (const line of bufferSnapshot.lines) {
|
|
917
|
+
plain.push(this._bufferLineToText(line));
|
|
918
|
+
html.push(this._bufferLineToHtml(line, cols));
|
|
919
|
+
}
|
|
920
|
+
return { plain, html };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
_findLineOverlap(prevRegion, nextRegion) {
|
|
924
|
+
const maxOverlap = Math.min(prevRegion.length, nextRegion.length);
|
|
925
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
926
|
+
let matches = true;
|
|
927
|
+
for (let idx = 0; idx < overlap; idx += 1) {
|
|
928
|
+
if (prevRegion[prevRegion.length - overlap + idx] !== nextRegion[idx]) {
|
|
929
|
+
matches = false;
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (matches) return overlap;
|
|
934
|
+
}
|
|
935
|
+
return 0;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
_trimAltScrollback() {
|
|
939
|
+
if (!Array.isArray(this.altScrollbackLines)) return;
|
|
940
|
+
const overflow = this.altScrollbackLines.length - this.transcriptMaxLines;
|
|
941
|
+
if (overflow > 0) {
|
|
942
|
+
this.altScrollbackLines.splice(0, overflow);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
_clearAltScrollbackState() {
|
|
947
|
+
this.altScrollbackLines = [];
|
|
948
|
+
this.altSnapshotPlain = null;
|
|
949
|
+
this.altSnapshotHtml = null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
_updateAltScrollback(snapshotPlain, snapshotHtml) {
|
|
953
|
+
if (!Array.isArray(snapshotPlain) || !Array.isArray(snapshotHtml)) return;
|
|
954
|
+
if (!Array.isArray(this.altScrollbackLines)) {
|
|
955
|
+
this.altScrollbackLines = [];
|
|
956
|
+
}
|
|
957
|
+
if (!Array.isArray(this.altSnapshotPlain)) {
|
|
958
|
+
this.altSnapshotPlain = snapshotPlain;
|
|
959
|
+
this.altSnapshotHtml = snapshotHtml;
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const prevPlain = this.altSnapshotPlain;
|
|
963
|
+
const prevHtml = this.altSnapshotHtml || [];
|
|
964
|
+
const nextPlain = snapshotPlain;
|
|
965
|
+
|
|
966
|
+
const len = Math.min(prevPlain.length, nextPlain.length);
|
|
967
|
+
let prefix = 0;
|
|
968
|
+
while (prefix < len && prevPlain[prefix] === nextPlain[prefix]) {
|
|
969
|
+
prefix += 1;
|
|
970
|
+
}
|
|
971
|
+
let suffix = 0;
|
|
972
|
+
while (
|
|
973
|
+
suffix < len - prefix &&
|
|
974
|
+
prevPlain[prevPlain.length - 1 - suffix] ===
|
|
975
|
+
nextPlain[nextPlain.length - 1 - suffix]
|
|
976
|
+
) {
|
|
977
|
+
suffix += 1;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const prevStart = prefix;
|
|
981
|
+
const prevEnd = prevPlain.length - suffix;
|
|
982
|
+
const nextStart = prefix;
|
|
983
|
+
const nextEnd = nextPlain.length - suffix;
|
|
984
|
+
|
|
985
|
+
const prevRegion = prevPlain.slice(prevStart, prevEnd);
|
|
986
|
+
const nextRegion = nextPlain.slice(nextStart, nextEnd);
|
|
987
|
+
const overlap = this._findLineOverlap(prevRegion, nextRegion);
|
|
988
|
+
if (overlap > 0) {
|
|
989
|
+
const removedCount = prevRegion.length - overlap;
|
|
990
|
+
if (removedCount > 0) {
|
|
991
|
+
const removedLines = prevHtml.slice(prevStart, prevStart + removedCount);
|
|
992
|
+
this.altScrollbackLines.push(...removedLines);
|
|
993
|
+
this._trimAltScrollback();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
this.altSnapshotPlain = nextPlain;
|
|
998
|
+
this.altSnapshotHtml = snapshotHtml;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
_paletteIndexToCss(index) {
|
|
1002
|
+
if (!Number.isInteger(index) || index < 0) return null;
|
|
1003
|
+
if (!this._xtermPalette) {
|
|
1004
|
+
const theme = CONSTANTS.THEME.XTERM;
|
|
1005
|
+
this._xtermPalette = [
|
|
1006
|
+
theme.black,
|
|
1007
|
+
theme.red,
|
|
1008
|
+
theme.green,
|
|
1009
|
+
theme.yellow,
|
|
1010
|
+
theme.blue,
|
|
1011
|
+
theme.magenta,
|
|
1012
|
+
theme.cyan,
|
|
1013
|
+
theme.white,
|
|
1014
|
+
theme.brightBlack,
|
|
1015
|
+
theme.brightRed,
|
|
1016
|
+
theme.brightGreen,
|
|
1017
|
+
theme.brightYellow,
|
|
1018
|
+
theme.brightBlue,
|
|
1019
|
+
theme.brightMagenta,
|
|
1020
|
+
theme.brightCyan,
|
|
1021
|
+
theme.brightWhite,
|
|
1022
|
+
];
|
|
1023
|
+
}
|
|
1024
|
+
if (index < this._xtermPalette.length) {
|
|
1025
|
+
return this._xtermPalette[index];
|
|
1026
|
+
}
|
|
1027
|
+
return this._ansi256ToRgb(index);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
_rgbNumberToCss(value) {
|
|
1031
|
+
if (!Number.isInteger(value) || value < 0) return null;
|
|
1032
|
+
const r = (value >> 16) & 0xff;
|
|
1033
|
+
const g = (value >> 8) & 0xff;
|
|
1034
|
+
const b = value & 0xff;
|
|
1035
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
_resolveXtermColor(mode, value) {
|
|
1039
|
+
if (!Number.isInteger(mode) || value === -1) return null;
|
|
1040
|
+
if (mode === XTERM_COLOR_MODE_DEFAULT) return null;
|
|
1041
|
+
if (mode === XTERM_COLOR_MODE_RGB) {
|
|
1042
|
+
return this._rgbNumberToCss(value);
|
|
1043
|
+
}
|
|
1044
|
+
if (
|
|
1045
|
+
mode === XTERM_COLOR_MODE_PALETTE_16 ||
|
|
1046
|
+
mode === XTERM_COLOR_MODE_PALETTE_256
|
|
1047
|
+
) {
|
|
1048
|
+
return this._paletteIndexToCss(value);
|
|
1049
|
+
}
|
|
1050
|
+
if (Number.isInteger(value)) {
|
|
1051
|
+
return value > 255 ? this._rgbNumberToCss(value) : this._paletteIndexToCss(value);
|
|
1052
|
+
}
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
_getCellStyle(cell) {
|
|
1057
|
+
const bold = typeof cell.isBold === "function" ? cell.isBold() : false;
|
|
1058
|
+
const inverse =
|
|
1059
|
+
typeof cell.isInverse === "function" ? cell.isInverse() : false;
|
|
1060
|
+
const fgMode =
|
|
1061
|
+
typeof cell.getFgColorMode === "function" ? cell.getFgColorMode() : null;
|
|
1062
|
+
const bgMode =
|
|
1063
|
+
typeof cell.getBgColorMode === "function" ? cell.getBgColorMode() : null;
|
|
1064
|
+
const fgValue =
|
|
1065
|
+
typeof cell.getFgColor === "function" ? cell.getFgColor() : null;
|
|
1066
|
+
const bgValue =
|
|
1067
|
+
typeof cell.getBgColor === "function" ? cell.getBgColor() : null;
|
|
1068
|
+
let fg = this._resolveXtermColor(fgMode, fgValue);
|
|
1069
|
+
let bg = this._resolveXtermColor(bgMode, bgValue);
|
|
1070
|
+
if (inverse) {
|
|
1071
|
+
const theme = CONSTANTS.THEME.XTERM;
|
|
1072
|
+
const defaultFg = theme.foreground;
|
|
1073
|
+
const defaultBg = theme.background;
|
|
1074
|
+
const resolvedFg = fg ?? defaultFg;
|
|
1075
|
+
const resolvedBg = bg ?? defaultBg;
|
|
1076
|
+
fg = resolvedBg;
|
|
1077
|
+
bg = resolvedFg;
|
|
1078
|
+
}
|
|
1079
|
+
const styles = [];
|
|
1080
|
+
if (fg) styles.push(`color: ${fg}`);
|
|
1081
|
+
if (bg) styles.push(`background-color: ${bg}`);
|
|
1082
|
+
return {
|
|
1083
|
+
className: bold ? "ansi-bold" : "",
|
|
1084
|
+
style: styles.join("; "),
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
_getCellWidth(cell) {
|
|
1089
|
+
if (typeof cell.getWidth === "function") {
|
|
1090
|
+
return cell.getWidth();
|
|
1091
|
+
}
|
|
1092
|
+
if (Number.isInteger(cell.width)) {
|
|
1093
|
+
return cell.width;
|
|
1094
|
+
}
|
|
1095
|
+
return 1;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
_getCellChars(cell, width) {
|
|
1099
|
+
let chars = "";
|
|
1100
|
+
if (typeof cell.getChars === "function") {
|
|
1101
|
+
chars = cell.getChars();
|
|
1102
|
+
}
|
|
1103
|
+
if (!chars && typeof cell.getCode === "function") {
|
|
1104
|
+
const code = cell.getCode();
|
|
1105
|
+
if (Number.isInteger(code) && code > 0) {
|
|
1106
|
+
chars = String.fromCodePoint(code);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (!chars) {
|
|
1110
|
+
chars = " ";
|
|
1111
|
+
}
|
|
1112
|
+
if (width > 1 && chars === " ") {
|
|
1113
|
+
return " ".repeat(width);
|
|
1114
|
+
}
|
|
1115
|
+
return chars;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
_bufferLineToHtml(line, cols) {
|
|
1119
|
+
if (!line) return "";
|
|
1120
|
+
if (typeof line.getCell !== "function") {
|
|
1121
|
+
return this._escapeHtml(this._bufferLineToText(line));
|
|
1122
|
+
}
|
|
1123
|
+
let html = "";
|
|
1124
|
+
let currentText = "";
|
|
1125
|
+
let currentClass = "";
|
|
1126
|
+
let currentStyle = "";
|
|
1127
|
+
const flush = () => {
|
|
1128
|
+
if (!currentText) return;
|
|
1129
|
+
const text = this._escapeHtml(currentText);
|
|
1130
|
+
if (!currentClass && !currentStyle) {
|
|
1131
|
+
html += text;
|
|
1132
|
+
} else if (currentClass && currentStyle) {
|
|
1133
|
+
html += `<span class="${currentClass}" style="${currentStyle}">${text}</span>`;
|
|
1134
|
+
} else if (currentClass) {
|
|
1135
|
+
html += `<span class="${currentClass}">${text}</span>`;
|
|
1136
|
+
} else {
|
|
1137
|
+
html += `<span style="${currentStyle}">${text}</span>`;
|
|
1138
|
+
}
|
|
1139
|
+
currentText = "";
|
|
1140
|
+
};
|
|
1141
|
+
const lineLength = Number.isInteger(line.length) ? line.length : this.term?.cols || 0;
|
|
1142
|
+
const maxCols = Number.isInteger(cols) ? cols : lineLength;
|
|
1143
|
+
for (let col = 0; col < maxCols; col++) {
|
|
1144
|
+
const cell = line.getCell(col);
|
|
1145
|
+
if (!cell) {
|
|
1146
|
+
if (currentClass || currentStyle) {
|
|
1147
|
+
flush();
|
|
1148
|
+
currentClass = "";
|
|
1149
|
+
currentStyle = "";
|
|
1150
|
+
}
|
|
1151
|
+
currentText += " ";
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const width = this._getCellWidth(cell);
|
|
1155
|
+
if (width === 0) {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const isInvisible =
|
|
1159
|
+
typeof cell.isInvisible === "function" ? cell.isInvisible() : false;
|
|
1160
|
+
const { className, style } = this._getCellStyle(cell);
|
|
1161
|
+
const chars = isInvisible
|
|
1162
|
+
? " ".repeat(Math.max(1, width))
|
|
1163
|
+
: this._getCellChars(cell, width);
|
|
1164
|
+
if (className !== currentClass || style !== currentStyle) {
|
|
1165
|
+
flush();
|
|
1166
|
+
currentClass = className;
|
|
1167
|
+
currentStyle = style;
|
|
1168
|
+
}
|
|
1169
|
+
currentText += chars;
|
|
1170
|
+
}
|
|
1171
|
+
flush();
|
|
1172
|
+
return html;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
_isAltBufferActive() {
|
|
1176
|
+
const bufferNamespace = this.term?.buffer;
|
|
1177
|
+
if (!bufferNamespace?.active || !bufferNamespace?.alternate) return false;
|
|
1178
|
+
return bufferNamespace.active === bufferNamespace.alternate;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
_pushTranscriptLine(lineCells) {
|
|
1182
|
+
this.transcriptLines.push(lineCells.slice());
|
|
1183
|
+
const overflow = this.transcriptLines.length - this.transcriptMaxLines;
|
|
1184
|
+
if (overflow > 0) {
|
|
1185
|
+
this.transcriptLines.splice(0, overflow);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
_cellsToSegments(cells) {
|
|
1190
|
+
if (!Array.isArray(cells)) return [];
|
|
1191
|
+
const segments = [];
|
|
1192
|
+
let current = null;
|
|
1193
|
+
for (const cell of cells) {
|
|
1194
|
+
if (!cell) continue;
|
|
1195
|
+
const cls = cell.c || "";
|
|
1196
|
+
const style = cell.s || "";
|
|
1197
|
+
if (!current || current.c !== cls || (current.s || "") !== style) {
|
|
1198
|
+
current = { t: cell.t || "", c: cls };
|
|
1199
|
+
if (style) {
|
|
1200
|
+
current.s = style;
|
|
1201
|
+
}
|
|
1202
|
+
segments.push(current);
|
|
1203
|
+
} else {
|
|
1204
|
+
current.t += cell.t || "";
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return segments;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
_segmentsToCells(segments) {
|
|
1211
|
+
if (typeof segments === "string") {
|
|
1212
|
+
return Array.from(segments).map((ch) => ({ t: ch, c: "", s: "" }));
|
|
1213
|
+
}
|
|
1214
|
+
if (!Array.isArray(segments)) return null;
|
|
1215
|
+
const cells = [];
|
|
1216
|
+
for (const seg of segments) {
|
|
1217
|
+
if (!seg || typeof seg.t !== "string") continue;
|
|
1218
|
+
const cls = typeof seg.c === "string" ? seg.c : "";
|
|
1219
|
+
const style = typeof seg.s === "string" ? seg.s : "";
|
|
1220
|
+
for (const ch of seg.t) {
|
|
1221
|
+
cells.push({ t: ch, c: cls, s: style });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return cells;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
_escapeHtml(text) {
|
|
1228
|
+
return String(text)
|
|
1229
|
+
.replace(/&/g, "&")
|
|
1230
|
+
.replace(/</g, "<")
|
|
1231
|
+
.replace(/>/g, ">")
|
|
1232
|
+
.replace(/"/g, """)
|
|
1233
|
+
.replace(/'/g, "'");
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
_cellsToHtml(cells) {
|
|
1237
|
+
if (!cells.length) return "";
|
|
1238
|
+
const segments = this._cellsToSegments(cells);
|
|
1239
|
+
let html = "";
|
|
1240
|
+
for (const seg of segments) {
|
|
1241
|
+
const text = this._escapeHtml(seg.t);
|
|
1242
|
+
if (!seg.c && !seg.s) {
|
|
1243
|
+
html += text;
|
|
1244
|
+
} else if (seg.c && seg.s) {
|
|
1245
|
+
html += `<span class="${seg.c}" style="${seg.s}">${text}</span>`;
|
|
1246
|
+
} else if (seg.c) {
|
|
1247
|
+
html += `<span class="${seg.c}">${text}</span>`;
|
|
1248
|
+
} else {
|
|
1249
|
+
html += `<span style="${seg.s}">${text}</span>`;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return html;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
_cellsToPlainText(cells) {
|
|
1256
|
+
if (!Array.isArray(cells) || !cells.length) return "";
|
|
1257
|
+
let text = "";
|
|
1258
|
+
for (const cell of cells) {
|
|
1259
|
+
if (!cell) {
|
|
1260
|
+
text += " ";
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
text += cell.t || " ";
|
|
1264
|
+
}
|
|
1265
|
+
return text;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
_hydrateTerminalFromTranscript() {
|
|
1269
|
+
if (!this.term || this.transcriptHydrated) return;
|
|
1270
|
+
const lines = this._getTranscriptLines();
|
|
1271
|
+
if (!lines.length) return;
|
|
1272
|
+
const output = lines.map((line) => this._cellsToPlainText(line)).join("\r\n");
|
|
1273
|
+
if (output) {
|
|
1274
|
+
this.term.write(output);
|
|
1275
|
+
this.transcriptHydrated = true;
|
|
1276
|
+
this._updateJumpBottomVisibility();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
_ansiClassName() {
|
|
1281
|
+
const state = this.transcriptAnsiState;
|
|
1282
|
+
const parts = [];
|
|
1283
|
+
if (state.bold) parts.push("ansi-bold");
|
|
1284
|
+
if (state.fg) parts.push(`ansi-fg-${state.fg}`);
|
|
1285
|
+
if (state.bg) parts.push(`ansi-bg-${state.bg}`);
|
|
1286
|
+
return parts.join(" ");
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
_ansiStyle() {
|
|
1290
|
+
const state = this.transcriptAnsiState;
|
|
1291
|
+
const styles = [];
|
|
1292
|
+
if (state.fgRgb) styles.push(`color: ${state.fgRgb}`);
|
|
1293
|
+
if (state.bgRgb) styles.push(`background-color: ${state.bgRgb}`);
|
|
1294
|
+
return styles.join("; ");
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
_ansi256ToRgb(value) {
|
|
1298
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
|
|
1299
|
+
if (value >= 232) {
|
|
1300
|
+
const shade = 8 + (value - 232) * 10;
|
|
1301
|
+
return `rgb(${shade}, ${shade}, ${shade})`;
|
|
1302
|
+
}
|
|
1303
|
+
if (value < 16) return null;
|
|
1304
|
+
const index = value - 16;
|
|
1305
|
+
const r = Math.floor(index / 36);
|
|
1306
|
+
const g = Math.floor((index % 36) / 6);
|
|
1307
|
+
const b = index % 6;
|
|
1308
|
+
const steps = [0, 95, 135, 175, 215, 255];
|
|
1309
|
+
return `rgb(${steps[r]}, ${steps[g]}, ${steps[b]})`;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
_applyAnsiPaletteColor(isForeground, value, state) {
|
|
1313
|
+
if (!Number.isInteger(value)) return;
|
|
1314
|
+
if (value >= 0 && value <= 7) {
|
|
1315
|
+
if (isForeground) {
|
|
1316
|
+
state.fg = String(30 + value);
|
|
1317
|
+
state.fgRgb = null;
|
|
1318
|
+
} else {
|
|
1319
|
+
state.bg = String(40 + value);
|
|
1320
|
+
state.bgRgb = null;
|
|
1321
|
+
}
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
if (value >= 8 && value <= 15) {
|
|
1325
|
+
if (isForeground) {
|
|
1326
|
+
state.fg = String(90 + (value - 8));
|
|
1327
|
+
state.fgRgb = null;
|
|
1328
|
+
} else {
|
|
1329
|
+
state.bg = String(100 + (value - 8));
|
|
1330
|
+
state.bgRgb = null;
|
|
1331
|
+
}
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
const rgb = this._ansi256ToRgb(value);
|
|
1335
|
+
if (!rgb) return;
|
|
1336
|
+
if (isForeground) {
|
|
1337
|
+
state.fg = null;
|
|
1338
|
+
state.fgRgb = rgb;
|
|
1339
|
+
} else {
|
|
1340
|
+
state.bg = null;
|
|
1341
|
+
state.bgRgb = rgb;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
_appendTranscriptChunk(data) {
|
|
1346
|
+
if (!data) return;
|
|
1347
|
+
const text =
|
|
1348
|
+
typeof data === "string"
|
|
1349
|
+
? data
|
|
1350
|
+
: this.transcriptDecoder.decode(data, { stream: true });
|
|
1351
|
+
if (!text) return;
|
|
1352
|
+
const state = this.transcriptAnsiState;
|
|
1353
|
+
let didChange = false;
|
|
1354
|
+
|
|
1355
|
+
const parseParams = (raw) => {
|
|
1356
|
+
if (!raw) return [];
|
|
1357
|
+
return raw.split(";").map((part) => {
|
|
1358
|
+
const match = part.match(/(\d+)/);
|
|
1359
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
1360
|
+
});
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
const getParam = (params, index, fallback) => {
|
|
1364
|
+
const value = params[index];
|
|
1365
|
+
return Number.isInteger(value) ? value : fallback;
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const writeChar = (char) => {
|
|
1369
|
+
if (this.transcriptCursor > this.transcriptLineCells.length) {
|
|
1370
|
+
const padCount = this.transcriptCursor - this.transcriptLineCells.length;
|
|
1371
|
+
for (let idx = 0; idx < padCount; idx++) {
|
|
1372
|
+
this.transcriptLineCells.push({ t: " ", c: "" });
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
const cell = { t: char, c: state.className };
|
|
1376
|
+
if (state.style) {
|
|
1377
|
+
cell.s = state.style;
|
|
1378
|
+
}
|
|
1379
|
+
if (this.transcriptCursor === this.transcriptLineCells.length) {
|
|
1380
|
+
this.transcriptLineCells.push(cell);
|
|
1381
|
+
} else {
|
|
1382
|
+
this.transcriptLineCells[this.transcriptCursor] = cell;
|
|
1383
|
+
}
|
|
1384
|
+
this.transcriptCursor += 1;
|
|
1385
|
+
didChange = true;
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
for (let i = 0; i < text.length; i++) {
|
|
1389
|
+
const ch = text[i];
|
|
1390
|
+
if (state.mode === "osc") {
|
|
1391
|
+
if (state.oscEsc) {
|
|
1392
|
+
state.oscEsc = false;
|
|
1393
|
+
if (ch === "\\") {
|
|
1394
|
+
state.mode = "text";
|
|
1395
|
+
}
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
if (ch === "\x07") {
|
|
1399
|
+
state.mode = "text";
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
if (ch === "\x1b") {
|
|
1403
|
+
state.oscEsc = true;
|
|
1404
|
+
}
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (state.mode === "csi") {
|
|
1409
|
+
if (ch >= "@" && ch <= "~") {
|
|
1410
|
+
const params = parseParams(state.csiParams);
|
|
1411
|
+
const param = getParam(params, 0, 0);
|
|
1412
|
+
if (ch === "m") {
|
|
1413
|
+
const codes = params.length ? params : [0];
|
|
1414
|
+
for (let idx = 0; idx < codes.length; idx++) {
|
|
1415
|
+
const code = codes[idx];
|
|
1416
|
+
if (code === 0 || code === null) {
|
|
1417
|
+
state.fg = null;
|
|
1418
|
+
state.bg = null;
|
|
1419
|
+
state.fgRgb = null;
|
|
1420
|
+
state.bgRgb = null;
|
|
1421
|
+
state.bold = false;
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
if (code === 1) {
|
|
1425
|
+
state.bold = true;
|
|
1426
|
+
continue;
|
|
1427
|
+
}
|
|
1428
|
+
if (code === 22) {
|
|
1429
|
+
state.bold = false;
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
if (code === 38 || code === 48) {
|
|
1433
|
+
const isForeground = code === 38;
|
|
1434
|
+
const mode = codes[idx + 1];
|
|
1435
|
+
if (mode === 2) {
|
|
1436
|
+
const r = codes[idx + 2];
|
|
1437
|
+
const g = codes[idx + 3];
|
|
1438
|
+
const b = codes[idx + 4];
|
|
1439
|
+
if (
|
|
1440
|
+
Number.isInteger(r) &&
|
|
1441
|
+
Number.isInteger(g) &&
|
|
1442
|
+
Number.isInteger(b)
|
|
1443
|
+
) {
|
|
1444
|
+
const rr = Math.max(0, Math.min(255, r));
|
|
1445
|
+
const gg = Math.max(0, Math.min(255, g));
|
|
1446
|
+
const bb = Math.max(0, Math.min(255, b));
|
|
1447
|
+
const rgb = `rgb(${rr}, ${gg}, ${bb})`;
|
|
1448
|
+
if (isForeground) {
|
|
1449
|
+
state.fg = null;
|
|
1450
|
+
state.fgRgb = rgb;
|
|
1451
|
+
} else {
|
|
1452
|
+
state.bg = null;
|
|
1453
|
+
state.bgRgb = rgb;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
idx += 4;
|
|
1457
|
+
} else if (mode === 5) {
|
|
1458
|
+
const colorIndex = codes[idx + 2];
|
|
1459
|
+
this._applyAnsiPaletteColor(isForeground, colorIndex, state);
|
|
1460
|
+
idx += 2;
|
|
1461
|
+
}
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
if (code >= 30 && code <= 37) {
|
|
1465
|
+
state.fg = String(code);
|
|
1466
|
+
state.fgRgb = null;
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
if (code === 39) {
|
|
1470
|
+
state.fg = null;
|
|
1471
|
+
state.fgRgb = null;
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
if (code >= 40 && code <= 47) {
|
|
1475
|
+
state.bg = String(code);
|
|
1476
|
+
state.bgRgb = null;
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
if (code === 49) {
|
|
1480
|
+
state.bg = null;
|
|
1481
|
+
state.bgRgb = null;
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (code >= 90 && code <= 97) {
|
|
1485
|
+
state.fg = String(code);
|
|
1486
|
+
state.fgRgb = null;
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
if (code >= 100 && code <= 107) {
|
|
1490
|
+
state.bg = String(code);
|
|
1491
|
+
state.bgRgb = null;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
state.className = this._ansiClassName();
|
|
1495
|
+
state.style = this._ansiStyle();
|
|
1496
|
+
} else if (ch === "K") {
|
|
1497
|
+
if (param === 2) {
|
|
1498
|
+
this.transcriptLineCells = [];
|
|
1499
|
+
this.transcriptCursor = 0;
|
|
1500
|
+
} else if (param === 1) {
|
|
1501
|
+
for (let idx = 0; idx < this.transcriptCursor; idx++) {
|
|
1502
|
+
if (this.transcriptLineCells[idx]) {
|
|
1503
|
+
this.transcriptLineCells[idx].t = " ";
|
|
1504
|
+
} else {
|
|
1505
|
+
this.transcriptLineCells[idx] = { t: " ", c: "" };
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
} else {
|
|
1509
|
+
this.transcriptLineCells = this.transcriptLineCells.slice(
|
|
1510
|
+
0,
|
|
1511
|
+
this.transcriptCursor
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
didChange = true;
|
|
1515
|
+
} else if (ch === "G") {
|
|
1516
|
+
this.transcriptCursor = Math.max(0, param - 1);
|
|
1517
|
+
} else if (ch === "C") {
|
|
1518
|
+
this.transcriptCursor = Math.max(0, this.transcriptCursor + (param || 1));
|
|
1519
|
+
} else if (ch === "D") {
|
|
1520
|
+
this.transcriptCursor = Math.max(0, this.transcriptCursor - (param || 1));
|
|
1521
|
+
} else if (ch === "H" || ch === "f") {
|
|
1522
|
+
const col = getParam(params, 1, getParam(params, 0, 1));
|
|
1523
|
+
this.transcriptCursor = Math.max(0, (col || 1) - 1);
|
|
1524
|
+
}
|
|
1525
|
+
state.mode = "text";
|
|
1526
|
+
state.csiParams = "";
|
|
1527
|
+
} else {
|
|
1528
|
+
state.csiParams += ch;
|
|
1529
|
+
}
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
if (state.mode === "esc") {
|
|
1534
|
+
if (ch === "[") {
|
|
1535
|
+
state.mode = "csi";
|
|
1536
|
+
state.csiParams = "";
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
if (ch === "]") {
|
|
1540
|
+
state.mode = "osc";
|
|
1541
|
+
state.oscEsc = false;
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
state.mode = "text";
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (ch === "\x1b") {
|
|
1549
|
+
state.mode = "esc";
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
if (ch === "\x07") {
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (ch === "\r") {
|
|
1556
|
+
this.transcriptCursor = 0;
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
if (ch === "\n") {
|
|
1560
|
+
this._pushTranscriptLine(this.transcriptLineCells);
|
|
1561
|
+
this.transcriptLineCells = [];
|
|
1562
|
+
this.transcriptCursor = 0;
|
|
1563
|
+
didChange = true;
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
if (ch === "\b") {
|
|
1567
|
+
if (this.transcriptCursor > 0) {
|
|
1568
|
+
const idx = this.transcriptCursor - 1;
|
|
1569
|
+
if (this.transcriptLineCells[idx]) {
|
|
1570
|
+
this.transcriptLineCells[idx].t = " ";
|
|
1571
|
+
}
|
|
1572
|
+
this.transcriptCursor = idx;
|
|
1573
|
+
didChange = true;
|
|
1574
|
+
}
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
if (ch >= " " || ch === "\t") {
|
|
1578
|
+
if (ch === "\t") {
|
|
1579
|
+
writeChar(" ");
|
|
1580
|
+
writeChar(" ");
|
|
1581
|
+
} else {
|
|
1582
|
+
writeChar(ch);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
if (didChange) {
|
|
1588
|
+
this._persistTranscriptSoon();
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
_initMobileView() {
|
|
1593
|
+
if (this.mobileViewEl) return;
|
|
1594
|
+
const existing = document.getElementById("mobile-terminal-view");
|
|
1595
|
+
if (existing) {
|
|
1596
|
+
this.mobileViewEl = existing;
|
|
1597
|
+
} else {
|
|
1598
|
+
this.mobileViewEl = document.createElement("div");
|
|
1599
|
+
this.mobileViewEl.id = "mobile-terminal-view";
|
|
1600
|
+
this.mobileViewEl.className = "mobile-terminal-view hidden";
|
|
1601
|
+
document.body.appendChild(this.mobileViewEl);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
this.mobileViewEl.addEventListener("scroll", () => {
|
|
1605
|
+
if (!this.mobileViewEl) return;
|
|
1606
|
+
this.mobileViewScrollTop = this.mobileViewEl.scrollTop;
|
|
1607
|
+
const threshold = 4;
|
|
1608
|
+
this.mobileViewAtBottom =
|
|
1609
|
+
this.mobileViewEl.scrollTop + this.mobileViewEl.clientHeight >=
|
|
1610
|
+
this.mobileViewEl.scrollHeight - threshold;
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
_setMobileViewActive(active) {
|
|
1615
|
+
if (!this.isTouchDevice() || !isMobileViewport()) return;
|
|
1616
|
+
this._initMobileView();
|
|
1617
|
+
if (!this.mobileViewEl) return;
|
|
1618
|
+
const wasActive = this.mobileViewActive;
|
|
1619
|
+
this.mobileViewActive = Boolean(active);
|
|
1620
|
+
if (!this.mobileViewActive) {
|
|
1621
|
+
this.mobileViewEl.classList.add("hidden");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (!wasActive) {
|
|
1625
|
+
this.mobileViewAtBottom = true;
|
|
1626
|
+
this.mobileViewScrollTop = null;
|
|
1627
|
+
} else {
|
|
1628
|
+
const buffer = this.term?.buffer?.active;
|
|
1629
|
+
if (buffer) {
|
|
1630
|
+
const atBottom = buffer.viewportY >= buffer.baseY;
|
|
1631
|
+
this.mobileViewAtBottom = atBottom;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const shouldScrollToBottom = this.mobileViewAtBottom;
|
|
1635
|
+
this.mobileViewSuppressAtBottomRecalc = true;
|
|
1636
|
+
this.mobileViewEl.classList.remove("hidden");
|
|
1637
|
+
this._renderMobileView();
|
|
1638
|
+
this.mobileViewSuppressAtBottomRecalc = false;
|
|
1639
|
+
if (shouldScrollToBottom) {
|
|
1640
|
+
requestAnimationFrame(() => {
|
|
1641
|
+
if (!this.mobileViewEl || !this.mobileViewActive) return;
|
|
1642
|
+
this.mobileViewEl.scrollTop = this.mobileViewEl.scrollHeight;
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
_scheduleMobileViewRender() {
|
|
1648
|
+
if (this.awaitingReplayEnd) {
|
|
1649
|
+
// Capture alt-screen scrollback during replay before renders coalesce.
|
|
1650
|
+
this._renderMobileView();
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
this.mobileViewDirty = true;
|
|
1654
|
+
if (this.mobileViewRaf) return;
|
|
1655
|
+
this.mobileViewRaf = requestAnimationFrame(() => {
|
|
1656
|
+
this.mobileViewRaf = null;
|
|
1657
|
+
if (!this.mobileViewDirty) return;
|
|
1658
|
+
this.mobileViewDirty = false;
|
|
1659
|
+
this._renderMobileView();
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
_recordAltBufferState() {
|
|
1664
|
+
if (!this.terminalDebug || !this.term) return;
|
|
1665
|
+
const active = this._isAltBufferActive();
|
|
1666
|
+
const buffer = this.term.buffer?.active;
|
|
1667
|
+
const baseY = buffer ? buffer.baseY : null;
|
|
1668
|
+
const viewportY = buffer ? buffer.viewportY : null;
|
|
1669
|
+
const size = Array.isArray(this.altScrollbackLines)
|
|
1670
|
+
? this.altScrollbackLines.length
|
|
1671
|
+
: 0;
|
|
1672
|
+
const changed =
|
|
1673
|
+
active !== this.lastAltBufferActive ||
|
|
1674
|
+
size !== this.lastAltScrollbackSize;
|
|
1675
|
+
if (!changed) return;
|
|
1676
|
+
this.lastAltBufferActive = active;
|
|
1677
|
+
this.lastAltScrollbackSize = size;
|
|
1678
|
+
this._logTerminalDebug("alt-buffer state", {
|
|
1679
|
+
active,
|
|
1680
|
+
scrollback: size,
|
|
1681
|
+
baseY,
|
|
1682
|
+
viewportY,
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
_renderMobileView() {
|
|
1687
|
+
if (!this.term) return;
|
|
1688
|
+
const shouldRender = this.mobileViewActive && this.mobileViewEl;
|
|
1689
|
+
const useAltBuffer = this._isAltBufferActive();
|
|
1690
|
+
if (!shouldRender && !useAltBuffer) {
|
|
1691
|
+
if (this.altSnapshotPlain || (this.altScrollbackLines || []).length) {
|
|
1692
|
+
this._clearAltScrollbackState();
|
|
1693
|
+
}
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const bufferSnapshot = this._getBufferSnapshot();
|
|
1697
|
+
if (!Array.isArray(bufferSnapshot?.lines)) {
|
|
1698
|
+
if (shouldRender) {
|
|
1699
|
+
this.mobileViewEl.innerHTML = "";
|
|
1700
|
+
}
|
|
1701
|
+
this._clearAltScrollbackState();
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const bufferSnapshotLines = this._snapshotBufferLines(bufferSnapshot);
|
|
1705
|
+
if (!bufferSnapshotLines?.html) {
|
|
1706
|
+
if (shouldRender) {
|
|
1707
|
+
this.mobileViewEl.innerHTML = "";
|
|
1708
|
+
}
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (useAltBuffer) {
|
|
1712
|
+
this._updateAltScrollback(
|
|
1713
|
+
bufferSnapshotLines.plain,
|
|
1714
|
+
bufferSnapshotLines.html
|
|
1715
|
+
);
|
|
1716
|
+
} else {
|
|
1717
|
+
// Reset alternate buffer scrollback when we're showing the normal buffer.
|
|
1718
|
+
this._clearAltScrollbackState();
|
|
1719
|
+
}
|
|
1720
|
+
this._recordAltBufferState();
|
|
1721
|
+
if (!shouldRender) return;
|
|
1722
|
+
// This view mirrors the live output as plain text; it is intentionally read-only
|
|
1723
|
+
// and is hidden whenever the user wants to interact with the real TUI.
|
|
1724
|
+
if (
|
|
1725
|
+
!this.mobileViewEl.classList.contains("hidden") &&
|
|
1726
|
+
!this.mobileViewSuppressAtBottomRecalc
|
|
1727
|
+
) {
|
|
1728
|
+
const threshold = 4;
|
|
1729
|
+
this.mobileViewAtBottom =
|
|
1730
|
+
this.mobileViewEl.scrollTop + this.mobileViewEl.clientHeight >=
|
|
1731
|
+
this.mobileViewEl.scrollHeight - threshold;
|
|
1732
|
+
}
|
|
1733
|
+
let content = "";
|
|
1734
|
+
if (useAltBuffer) {
|
|
1735
|
+
for (const line of this.altScrollbackLines || []) {
|
|
1736
|
+
content += `${line}\n`;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
for (const line of bufferSnapshotLines.html) {
|
|
1740
|
+
content += `${line}\n`;
|
|
1741
|
+
}
|
|
1742
|
+
this.mobileViewEl.innerHTML = content;
|
|
1743
|
+
if (this.mobileViewAtBottom) {
|
|
1744
|
+
this.mobileViewEl.scrollTop = this.mobileViewEl.scrollHeight;
|
|
1745
|
+
} else if (this.mobileViewScrollTop !== null) {
|
|
1746
|
+
const maxScroll =
|
|
1747
|
+
this.mobileViewEl.scrollHeight - this.mobileViewEl.clientHeight;
|
|
1748
|
+
this.mobileViewEl.scrollTop = Math.min(this.mobileViewScrollTop, maxScroll);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* Ensure xterm terminal is initialized
|
|
1754
|
+
*/
|
|
1755
|
+
_ensureTerminal() {
|
|
1756
|
+
if (!window.Terminal || !window.FitAddon) {
|
|
1757
|
+
this._setStatus("xterm assets missing; reload or check /static/vendor");
|
|
1758
|
+
flash("xterm assets missing; reload the page", "error");
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
if (this.term) {
|
|
1762
|
+
return true;
|
|
1763
|
+
}
|
|
1764
|
+
const container = document.getElementById("terminal-container");
|
|
1765
|
+
if (!container) return false;
|
|
1766
|
+
|
|
1767
|
+
this.term = new window.Terminal({
|
|
1768
|
+
convertEol: true,
|
|
1769
|
+
fontFamily:
|
|
1770
|
+
'"JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
|
|
1771
|
+
fontSize: this._getFontSize(),
|
|
1772
|
+
scrollSensitivity: 1,
|
|
1773
|
+
fastScrollSensitivity: 5,
|
|
1774
|
+
cursorBlink: true,
|
|
1775
|
+
rows: 24,
|
|
1776
|
+
cols: 100,
|
|
1777
|
+
scrollback: this.transcriptMaxLines,
|
|
1778
|
+
theme: CONSTANTS.THEME.XTERM,
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
this.fitAddon = new window.FitAddon.FitAddon();
|
|
1782
|
+
this.term.loadAddon(this.fitAddon);
|
|
1783
|
+
this.term.open(container);
|
|
1784
|
+
this.term.write('Press "New" or "Resume" to launch Codex TUI...\r\n');
|
|
1785
|
+
this._installWheelScroll();
|
|
1786
|
+
this._installTouchScroll();
|
|
1787
|
+
this.term.onScroll(() => this._updateJumpBottomVisibility());
|
|
1788
|
+
this.term.onRender(() => this._scheduleMobileViewRender());
|
|
1789
|
+
this._updateJumpBottomVisibility();
|
|
1790
|
+
|
|
1791
|
+
if (!this.inputDisposable) {
|
|
1792
|
+
this.inputDisposable = this.term.onData((data) => {
|
|
1793
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
|
1794
|
+
this._markSessionActive();
|
|
1795
|
+
this.socket.send(textEncoder.encode(data));
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
return true;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
_installWheelScroll() {
|
|
1802
|
+
if (this.wheelScrollInstalled || !this.term || !this.term.element) return;
|
|
1803
|
+
if (this.isTouchDevice()) return;
|
|
1804
|
+
|
|
1805
|
+
const wheelTarget = this.term.element;
|
|
1806
|
+
const wheelListener = (event) => {
|
|
1807
|
+
if (!this.term || !event) return;
|
|
1808
|
+
if (event.ctrlKey) return;
|
|
1809
|
+
const buffer = this.term.buffer?.active;
|
|
1810
|
+
const mouseTracking = this.term?.modes?.mouseTrackingMode;
|
|
1811
|
+
// Let the TUI handle wheel events when mouse tracking is active.
|
|
1812
|
+
if (mouseTracking && mouseTracking !== "none") {
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
// Only consume wheel events when xterm has scrollback; alt screen should pass through to TUI.
|
|
1816
|
+
if (!buffer || buffer.baseY <= 0) {
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
event.preventDefault();
|
|
1821
|
+
event.stopImmediatePropagation();
|
|
1822
|
+
|
|
1823
|
+
let deltaLines = 0;
|
|
1824
|
+
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
|
1825
|
+
deltaLines = event.deltaY;
|
|
1826
|
+
} else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
|
1827
|
+
deltaLines = event.deltaY * this.term.rows;
|
|
1828
|
+
} else {
|
|
1829
|
+
deltaLines = event.deltaY / 40;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const options = this.term.options || {};
|
|
1833
|
+
if (Number.isFinite(options.scrollSensitivity)) {
|
|
1834
|
+
deltaLines *= options.scrollSensitivity;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Respect xterm's fast-scroll modifier and sensitivity settings.
|
|
1838
|
+
const modifier = options.fastScrollModifier || "alt";
|
|
1839
|
+
const fastSensitivity = Number.isFinite(options.fastScrollSensitivity)
|
|
1840
|
+
? options.fastScrollSensitivity
|
|
1841
|
+
: 5;
|
|
1842
|
+
const modifierActive =
|
|
1843
|
+
modifier !== "none" &&
|
|
1844
|
+
((modifier === "alt" && event.altKey) ||
|
|
1845
|
+
(modifier === "ctrl" && event.ctrlKey) ||
|
|
1846
|
+
(modifier === "shift" && event.shiftKey) ||
|
|
1847
|
+
(modifier === "meta" && event.metaKey));
|
|
1848
|
+
if (modifierActive) {
|
|
1849
|
+
deltaLines *= fastSensitivity;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
this.wheelScrollRemainder += deltaLines;
|
|
1853
|
+
const wholeLines = Math.trunc(this.wheelScrollRemainder);
|
|
1854
|
+
if (wholeLines !== 0) {
|
|
1855
|
+
this.term.scrollLines(wholeLines);
|
|
1856
|
+
this.wheelScrollRemainder -= wholeLines;
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
|
|
1860
|
+
wheelTarget.addEventListener("wheel", wheelListener, {
|
|
1861
|
+
passive: false,
|
|
1862
|
+
capture: true,
|
|
1863
|
+
});
|
|
1864
|
+
this.wheelScrollInstalled = true;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
_installTouchScroll() {
|
|
1868
|
+
if (this.touchScrollInstalled || !this.term || !this.term.element) return;
|
|
1869
|
+
if (!this.isTouchDevice()) return;
|
|
1870
|
+
|
|
1871
|
+
// Mobile Safari doesn't scroll the canvas-based xterm viewport reliably,
|
|
1872
|
+
// so translate touch movement into scrollLines when scrollback exists.
|
|
1873
|
+
const viewport = this.term.element.querySelector(".xterm-viewport");
|
|
1874
|
+
if (!viewport) return;
|
|
1875
|
+
|
|
1876
|
+
const getLineHeight = () => {
|
|
1877
|
+
const dims = this.term?._core?._renderService?.dimensions;
|
|
1878
|
+
if (dims && Number.isFinite(dims.actualCellHeight) && dims.actualCellHeight > 0) {
|
|
1879
|
+
return dims.actualCellHeight;
|
|
1880
|
+
}
|
|
1881
|
+
const fontSize =
|
|
1882
|
+
typeof this.term.options?.fontSize === "number" ? this.term.options.fontSize : 14;
|
|
1883
|
+
return Math.max(10, Math.round(fontSize * 1.2));
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
const handleTouchStart = (event) => {
|
|
1887
|
+
if (!event.touches || event.touches.length !== 1) return;
|
|
1888
|
+
this.touchScrollLastY = event.touches[0].clientY;
|
|
1889
|
+
this.touchScrollRemainder = 0;
|
|
1890
|
+
};
|
|
1891
|
+
|
|
1892
|
+
const handleTouchMove = (event) => {
|
|
1893
|
+
if (!event.touches || event.touches.length !== 1) return;
|
|
1894
|
+
if (!this.term || this.mobileViewActive) return;
|
|
1895
|
+
const mouseTracking = this.term?.modes?.mouseTrackingMode;
|
|
1896
|
+
if (mouseTracking && mouseTracking !== "none") {
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
const buffer = this.term.buffer?.active;
|
|
1900
|
+
if (!buffer || buffer.baseY <= 0) return;
|
|
1901
|
+
const currentY = event.touches[0].clientY;
|
|
1902
|
+
if (!Number.isFinite(this.touchScrollLastY)) {
|
|
1903
|
+
this.touchScrollLastY = currentY;
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
const delta = currentY - this.touchScrollLastY;
|
|
1907
|
+
this.touchScrollLastY = currentY;
|
|
1908
|
+
this.touchScrollRemainder += delta;
|
|
1909
|
+
const lineHeight = getLineHeight();
|
|
1910
|
+
const lines = Math.trunc(this.touchScrollRemainder / lineHeight);
|
|
1911
|
+
if (lines === 0) return;
|
|
1912
|
+
this.touchScrollRemainder -= lines * lineHeight;
|
|
1913
|
+
this.term.scrollLines(-lines);
|
|
1914
|
+
event.preventDefault();
|
|
1915
|
+
event.stopPropagation();
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
const handleTouchEnd = () => {
|
|
1919
|
+
this.touchScrollLastY = null;
|
|
1920
|
+
this.touchScrollRemainder = 0;
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1923
|
+
viewport.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
1924
|
+
viewport.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
1925
|
+
viewport.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
1926
|
+
viewport.addEventListener("touchcancel", handleTouchEnd, { passive: true });
|
|
1927
|
+
this.touchScrollInstalled = true;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* Clean up WebSocket connection
|
|
1932
|
+
*/
|
|
1933
|
+
_teardownSocket() {
|
|
1934
|
+
if (this.socket) {
|
|
1935
|
+
this.socket.onclose = null;
|
|
1936
|
+
this.socket.onerror = null;
|
|
1937
|
+
this.socket.onmessage = null;
|
|
1938
|
+
this.socket.onopen = null;
|
|
1939
|
+
try {
|
|
1940
|
+
this.socket.close();
|
|
1941
|
+
} catch (err) {
|
|
1942
|
+
// ignore
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
this.socket = null;
|
|
1946
|
+
this.awaitingReplayEnd = false;
|
|
1947
|
+
this.replayBuffer = null;
|
|
1948
|
+
this.replayPrelude = null;
|
|
1949
|
+
this.pendingReplayPrelude = null;
|
|
1950
|
+
this.clearTranscriptOnFirstLiveData = false;
|
|
1951
|
+
this.transcriptResetForConnect = false;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* Update button enabled states
|
|
1956
|
+
*/
|
|
1957
|
+
_updateButtons(connected) {
|
|
1958
|
+
if (this.connectBtn) this.connectBtn.disabled = connected;
|
|
1959
|
+
if (this.disconnectBtn) this.disconnectBtn.disabled = !connected;
|
|
1960
|
+
if (this.resumeBtn) this.resumeBtn.disabled = connected;
|
|
1961
|
+
this._updateTextInputConnected(connected);
|
|
1962
|
+
|
|
1963
|
+
const voiceUnavailable = this.voiceBtn?.classList.contains("disabled");
|
|
1964
|
+
if (this.voiceBtn && !voiceUnavailable) {
|
|
1965
|
+
this.voiceBtn.disabled = !connected;
|
|
1966
|
+
this.voiceBtn.classList.toggle("voice-disconnected", !connected);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// Also update mobile voice button state
|
|
1970
|
+
const mobileVoiceUnavailable = this.mobileVoiceBtn?.classList.contains("disabled");
|
|
1971
|
+
if (this.mobileVoiceBtn && !mobileVoiceUnavailable) {
|
|
1972
|
+
this.mobileVoiceBtn.disabled = !connected;
|
|
1973
|
+
this.mobileVoiceBtn.classList.toggle("voice-disconnected", !connected);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
if (this.voiceStatus && !voiceUnavailable && !connected) {
|
|
1977
|
+
this.voiceStatus.textContent = "Connect to use voice";
|
|
1978
|
+
this.voiceStatus.classList.remove("hidden");
|
|
1979
|
+
} else if (
|
|
1980
|
+
this.voiceStatus &&
|
|
1981
|
+
!voiceUnavailable &&
|
|
1982
|
+
connected &&
|
|
1983
|
+
this.voiceController &&
|
|
1984
|
+
this.voiceStatus.textContent === "Connect to use voice"
|
|
1985
|
+
) {
|
|
1986
|
+
this.voiceStatus.textContent = "Hold to talk (Alt+V)";
|
|
1987
|
+
this.voiceStatus.classList.remove("hidden");
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* Handle terminal resize
|
|
1993
|
+
*/
|
|
1994
|
+
_handleResize() {
|
|
1995
|
+
if (!this.fitAddon || !this.term) return;
|
|
1996
|
+
|
|
1997
|
+
// Update font size based on current window width
|
|
1998
|
+
const newFontSize = this._getFontSize();
|
|
1999
|
+
if (this.term.options.fontSize !== newFontSize) {
|
|
2000
|
+
this.term.options.fontSize = newFontSize;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Only send resize if connected
|
|
2004
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
2005
|
+
try {
|
|
2006
|
+
this.fitAddon.fit();
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
// ignore fit errors when not visible
|
|
2009
|
+
}
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
this.fitAddon.fit();
|
|
2014
|
+
this.socket.send(
|
|
2015
|
+
JSON.stringify({
|
|
2016
|
+
type: "resize",
|
|
2017
|
+
cols: this.term.cols,
|
|
2018
|
+
rows: this.term.rows,
|
|
2019
|
+
})
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/**
|
|
2024
|
+
* Schedule resize after layout changes
|
|
2025
|
+
*/
|
|
2026
|
+
_scheduleResizeAfterLayout() {
|
|
2027
|
+
if (this.resizeRaf) {
|
|
2028
|
+
cancelAnimationFrame(this.resizeRaf);
|
|
2029
|
+
this.resizeRaf = null;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Double-rAF helps ensure layout changes have applied
|
|
2033
|
+
this.resizeRaf = requestAnimationFrame(() => {
|
|
2034
|
+
this.resizeRaf = requestAnimationFrame(() => {
|
|
2035
|
+
this.resizeRaf = null;
|
|
2036
|
+
this._updateViewportInsets();
|
|
2037
|
+
this._handleResize();
|
|
2038
|
+
if (this.deferScrollRestore) {
|
|
2039
|
+
this.deferScrollRestore = false;
|
|
2040
|
+
this._restoreTerminalScrollState();
|
|
2041
|
+
}
|
|
2042
|
+
});
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
scheduleResizeAfterLayout() {
|
|
2047
|
+
this._scheduleResizeAfterLayout();
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
_updateViewportInsets() {
|
|
2051
|
+
const viewportHeight = window.innerHeight;
|
|
2052
|
+
if (viewportHeight > this.baseViewportHeight) {
|
|
2053
|
+
this.baseViewportHeight = viewportHeight;
|
|
2054
|
+
}
|
|
2055
|
+
let bottom = 0;
|
|
2056
|
+
let top = 0;
|
|
2057
|
+
const vv = window.visualViewport;
|
|
2058
|
+
if (vv) {
|
|
2059
|
+
const layoutHeight = document.documentElement?.clientHeight || viewportHeight;
|
|
2060
|
+
const vvOffset = Math.max(0, vv.offsetTop);
|
|
2061
|
+
top = vvOffset;
|
|
2062
|
+
bottom = Math.max(0, layoutHeight - (vv.height + vvOffset));
|
|
2063
|
+
}
|
|
2064
|
+
const keyboardFallback = vv ? 0 : Math.max(0, this.baseViewportHeight - viewportHeight);
|
|
2065
|
+
const inset = bottom || keyboardFallback;
|
|
2066
|
+
document.documentElement.style.setProperty("--vv-bottom", `${inset}px`);
|
|
2067
|
+
document.documentElement.style.setProperty("--vv-top", `${top}px`);
|
|
2068
|
+
this.terminalSectionEl?.style.setProperty("--vv-bottom", `${inset}px`);
|
|
2069
|
+
this.terminalSectionEl?.style.setProperty("--vv-top", `${top}px`);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
_updateComposerSticky() {
|
|
2073
|
+
if (!this.terminalSectionEl) return;
|
|
2074
|
+
if (!this.isTouchDevice() || !this.textInputEnabled || !this.textInputTextareaEl) {
|
|
2075
|
+
this.terminalSectionEl.classList.remove("composer-sticky");
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
const hasText = Boolean((this.textInputTextareaEl.value || "").trim());
|
|
2079
|
+
const focused = document.activeElement === this.textInputTextareaEl;
|
|
2080
|
+
this.terminalSectionEl.classList.toggle("composer-sticky", hasText || focused);
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/**
|
|
2084
|
+
* Connect to the terminal WebSocket
|
|
2085
|
+
*/
|
|
2086
|
+
connect(options = {}) {
|
|
2087
|
+
const mode = (options.mode || (options.resume ? "resume" : "new")).toLowerCase();
|
|
2088
|
+
const isAttach = mode === "attach";
|
|
2089
|
+
const isResume = mode === "resume";
|
|
2090
|
+
const shouldAwaitReplay = isAttach || isResume;
|
|
2091
|
+
const quiet = Boolean(options.quiet);
|
|
2092
|
+
|
|
2093
|
+
this.sessionNotFound = false;
|
|
2094
|
+
if (!this._ensureTerminal()) return;
|
|
2095
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) return;
|
|
2096
|
+
|
|
2097
|
+
// Cancel any pending reconnect
|
|
2098
|
+
if (this.reconnectTimer) {
|
|
2099
|
+
clearTimeout(this.reconnectTimer);
|
|
2100
|
+
this.reconnectTimer = null;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
this._teardownSocket();
|
|
2104
|
+
this.intentionalDisconnect = false;
|
|
2105
|
+
this.lastConnectMode = mode;
|
|
2106
|
+
|
|
2107
|
+
this.awaitingReplayEnd = shouldAwaitReplay;
|
|
2108
|
+
this.replayBuffer = shouldAwaitReplay ? [] : null;
|
|
2109
|
+
this.replayPrelude = null;
|
|
2110
|
+
this.pendingReplayPrelude = null;
|
|
2111
|
+
this.clearTranscriptOnFirstLiveData = false;
|
|
2112
|
+
this.transcriptResetForConnect = false;
|
|
2113
|
+
this._resetTerminalDebugCounters();
|
|
2114
|
+
this.lastAltBufferActive = null;
|
|
2115
|
+
this.lastAltScrollbackSize = 0;
|
|
2116
|
+
if (!isAttach && !isResume) {
|
|
2117
|
+
this._resetTranscript();
|
|
2118
|
+
this.transcriptResetForConnect = true;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const queryParams = new URLSearchParams();
|
|
2122
|
+
if (mode) queryParams.append("mode", mode);
|
|
2123
|
+
if (this.terminalDebug) queryParams.append("terminal_debug", "1");
|
|
2124
|
+
|
|
2125
|
+
const savedSessionId = this._getSavedSessionId();
|
|
2126
|
+
this._logTerminalDebug("connect", {
|
|
2127
|
+
mode,
|
|
2128
|
+
shouldAwaitReplay,
|
|
2129
|
+
savedSessionId,
|
|
2130
|
+
});
|
|
2131
|
+
if (isAttach) {
|
|
2132
|
+
if (savedSessionId) {
|
|
2133
|
+
this._setCurrentSessionId(savedSessionId);
|
|
2134
|
+
queryParams.append("session_id", savedSessionId);
|
|
2135
|
+
} else {
|
|
2136
|
+
if (!quiet) flash("No saved terminal session to attach to", "error");
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
} else {
|
|
2140
|
+
// Starting a new PTY session should not accidentally attach to an old session
|
|
2141
|
+
if (savedSessionId) {
|
|
2142
|
+
queryParams.append("close_session_id", savedSessionId);
|
|
2143
|
+
}
|
|
2144
|
+
this._clearSavedSessionId();
|
|
2145
|
+
this._setCurrentSessionId(null);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const queryString = queryParams.toString();
|
|
2149
|
+
const wsUrl = buildWsUrl(
|
|
2150
|
+
CONSTANTS.API.TERMINAL_ENDPOINT,
|
|
2151
|
+
queryString ? `?${queryString}` : ""
|
|
2152
|
+
);
|
|
2153
|
+
const token = getAuthToken();
|
|
2154
|
+
const encodedToken = token ? base64UrlEncode(token) : null;
|
|
2155
|
+
const protocols = encodedToken ? [`car-token-b64.${encodedToken}`] : undefined;
|
|
2156
|
+
this.socket = protocols ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl);
|
|
2157
|
+
this.socket.binaryType = "arraybuffer";
|
|
2158
|
+
|
|
2159
|
+
this.socket.onopen = () => {
|
|
2160
|
+
this.reconnectAttempts = 0;
|
|
2161
|
+
this.overlayEl?.classList.add("hidden");
|
|
2162
|
+
this._markSessionActive();
|
|
2163
|
+
this._logTerminalDebug("socket open", {
|
|
2164
|
+
mode,
|
|
2165
|
+
sessionId: this.currentSessionId,
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
// On attach/resume, clear the local terminal first.
|
|
2169
|
+
if ((isAttach || isResume) && this.term) {
|
|
2170
|
+
this._resetTerminalDisplay();
|
|
2171
|
+
this.transcriptHydrated = false;
|
|
2172
|
+
this._hydrateTerminalFromTranscript();
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
if (isAttach) this._setStatus("Connected (reattached)");
|
|
2176
|
+
else if (isResume) this._setStatus("Connected (codex resume)");
|
|
2177
|
+
else this._setStatus("Connected");
|
|
2178
|
+
|
|
2179
|
+
this._updateButtons(true);
|
|
2180
|
+
this._updateTextInputSendUi();
|
|
2181
|
+
this.fitAddon.fit();
|
|
2182
|
+
this._handleResize();
|
|
2183
|
+
|
|
2184
|
+
if (isResume) this.term?.write("\r\nLaunching codex resume...\r\n");
|
|
2185
|
+
|
|
2186
|
+
if (this.textInputPending) {
|
|
2187
|
+
this._sendPendingTextInputChunk();
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
|
|
2191
|
+
this.socket.onmessage = (event) => {
|
|
2192
|
+
this._markSessionActive();
|
|
2193
|
+
if (typeof event.data === "string") {
|
|
2194
|
+
try {
|
|
2195
|
+
const payload = JSON.parse(event.data);
|
|
2196
|
+
if (payload.type === "hello") {
|
|
2197
|
+
if (payload.session_id) {
|
|
2198
|
+
this._setSavedSessionId(payload.session_id);
|
|
2199
|
+
this._setCurrentSessionId(payload.session_id);
|
|
2200
|
+
}
|
|
2201
|
+
this._markSessionActive();
|
|
2202
|
+
this._logTerminalDebug("hello", {
|
|
2203
|
+
sessionId: payload.session_id || null,
|
|
2204
|
+
});
|
|
2205
|
+
} else if (payload.type === "replay_end") {
|
|
2206
|
+
if (!this.awaitingReplayEnd) {
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
const buffered = Array.isArray(this.replayBuffer) ? this.replayBuffer : [];
|
|
2210
|
+
const prelude = this.replayPrelude;
|
|
2211
|
+
const hasReplay = buffered.length > 0;
|
|
2212
|
+
const hasAltScreenEnter =
|
|
2213
|
+
hasReplay && this._replayHasAltScreenEnter(buffered);
|
|
2214
|
+
const shouldApplyPrelude = Boolean(prelude && !hasAltScreenEnter);
|
|
2215
|
+
this._logTerminalDebug("replay_end", {
|
|
2216
|
+
chunks: buffered.length,
|
|
2217
|
+
bytes: this.replayByteCount,
|
|
2218
|
+
prelude: Boolean(prelude),
|
|
2219
|
+
hasAltScreenEnter,
|
|
2220
|
+
shouldApplyPrelude,
|
|
2221
|
+
clearOnLive: !this.transcriptResetForConnect,
|
|
2222
|
+
altScrollback: Array.isArray(this.altScrollbackLines)
|
|
2223
|
+
? this.altScrollbackLines.length
|
|
2224
|
+
: 0,
|
|
2225
|
+
});
|
|
2226
|
+
this.awaitingReplayEnd = false;
|
|
2227
|
+
this.replayBuffer = null;
|
|
2228
|
+
this.replayPrelude = null;
|
|
2229
|
+
if (hasReplay && this.term) {
|
|
2230
|
+
this._resetTranscript();
|
|
2231
|
+
this._resetTerminalDisplay();
|
|
2232
|
+
if (shouldApplyPrelude) {
|
|
2233
|
+
this._applyReplayPrelude(prelude);
|
|
2234
|
+
}
|
|
2235
|
+
for (const chunk of buffered) {
|
|
2236
|
+
this._appendTranscriptChunk(chunk);
|
|
2237
|
+
this._scheduleMobileViewRender();
|
|
2238
|
+
this.term.write(chunk);
|
|
2239
|
+
}
|
|
2240
|
+
if (this.terminalDebug) {
|
|
2241
|
+
this.term.write("", () => {
|
|
2242
|
+
this._logBufferSnapshot("replay_end_post");
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
} else {
|
|
2246
|
+
this.clearTranscriptOnFirstLiveData = !this.transcriptResetForConnect;
|
|
2247
|
+
this.pendingReplayPrelude = shouldApplyPrelude ? prelude : null;
|
|
2248
|
+
this._logBufferSnapshot("replay_end_empty");
|
|
2249
|
+
}
|
|
2250
|
+
} else if (payload.type === "ack") {
|
|
2251
|
+
this._handleTextInputAck(payload);
|
|
2252
|
+
} else if (payload.type === "exit") {
|
|
2253
|
+
this.term?.write(
|
|
2254
|
+
`\r\n[session ended${
|
|
2255
|
+
payload.code !== null ? ` (code ${payload.code})` : ""
|
|
2256
|
+
}] \r\n`
|
|
2257
|
+
);
|
|
2258
|
+
this._clearSavedSessionId();
|
|
2259
|
+
this._clearSavedSessionTimestamp();
|
|
2260
|
+
this._setCurrentSessionId(null);
|
|
2261
|
+
this.intentionalDisconnect = true;
|
|
2262
|
+
this.disconnect();
|
|
2263
|
+
} else if (payload.type === "error") {
|
|
2264
|
+
if (payload.message && payload.message.includes("Session not found")) {
|
|
2265
|
+
this.sessionNotFound = true;
|
|
2266
|
+
this._clearSavedSessionId();
|
|
2267
|
+
this._clearSavedSessionTimestamp();
|
|
2268
|
+
this._setCurrentSessionId(null);
|
|
2269
|
+
if (this.lastConnectMode === "attach") {
|
|
2270
|
+
if (!this.suppressNextNotFoundFlash) {
|
|
2271
|
+
flash(payload.message || "Terminal error", "error");
|
|
2272
|
+
}
|
|
2273
|
+
this.suppressNextNotFoundFlash = false;
|
|
2274
|
+
this.disconnect();
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
this._updateTextInputSendUi();
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
flash(payload.message || "Terminal error", "error");
|
|
2281
|
+
}
|
|
2282
|
+
} catch (err) {
|
|
2283
|
+
// ignore bad payloads
|
|
2284
|
+
}
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
if (this.term) {
|
|
2288
|
+
const chunk = new Uint8Array(event.data);
|
|
2289
|
+
if (this.awaitingReplayEnd) {
|
|
2290
|
+
this.replayChunkCount += 1;
|
|
2291
|
+
this.replayByteCount += chunk.length;
|
|
2292
|
+
const replayEmpty =
|
|
2293
|
+
Array.isArray(this.replayBuffer) && this.replayBuffer.length === 0;
|
|
2294
|
+
if (!this.replayPrelude && replayEmpty && this._isAltScreenEnterChunk(chunk)) {
|
|
2295
|
+
this.replayPrelude = chunk;
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
this.replayBuffer?.push(chunk);
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
if (this.clearTranscriptOnFirstLiveData) {
|
|
2302
|
+
this.clearTranscriptOnFirstLiveData = false;
|
|
2303
|
+
this._resetTranscript();
|
|
2304
|
+
this._resetTerminalDisplay();
|
|
2305
|
+
const hadPrelude = Boolean(this.pendingReplayPrelude);
|
|
2306
|
+
if (this.pendingReplayPrelude) {
|
|
2307
|
+
this._applyReplayPrelude(this.pendingReplayPrelude);
|
|
2308
|
+
this.pendingReplayPrelude = null;
|
|
2309
|
+
}
|
|
2310
|
+
this._logTerminalDebug("first_live_reset", {
|
|
2311
|
+
pendingPrelude: hadPrelude,
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
this.liveChunkCount += 1;
|
|
2315
|
+
this.liveByteCount += chunk.length;
|
|
2316
|
+
this._appendTranscriptChunk(chunk);
|
|
2317
|
+
this._scheduleMobileViewRender();
|
|
2318
|
+
this.term.write(chunk);
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
this.socket.onerror = () => {
|
|
2323
|
+
this._setStatus("Connection error");
|
|
2324
|
+
};
|
|
2325
|
+
|
|
2326
|
+
this.socket.onclose = () => {
|
|
2327
|
+
this._updateButtons(false);
|
|
2328
|
+
this._updateTextInputSendUi();
|
|
2329
|
+
|
|
2330
|
+
if (this.intentionalDisconnect) {
|
|
2331
|
+
this._setStatus("Disconnected");
|
|
2332
|
+
this.overlayEl?.classList.remove("hidden");
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
if (this.textInputPending) {
|
|
2337
|
+
flash("Send not confirmed; your text is preserved and will retry on reconnect", "info");
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// Auto-reconnect logic
|
|
2341
|
+
const savedId = this._getSavedSessionId();
|
|
2342
|
+
if (!savedId) {
|
|
2343
|
+
this._setStatus("Disconnected");
|
|
2344
|
+
this.overlayEl?.classList.remove("hidden");
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
if (this.reconnectAttempts < 3) {
|
|
2349
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 8000);
|
|
2350
|
+
this._setStatus(`Reconnecting in ${Math.round(delay / 100)}s...`);
|
|
2351
|
+
this.reconnectAttempts++;
|
|
2352
|
+
this.reconnectTimer = setTimeout(() => {
|
|
2353
|
+
this.suppressNextNotFoundFlash = true;
|
|
2354
|
+
this.connect({ mode: "attach", quiet: true });
|
|
2355
|
+
}, delay);
|
|
2356
|
+
} else {
|
|
2357
|
+
this._setStatus("Disconnected (max retries reached)");
|
|
2358
|
+
this.overlayEl?.classList.remove("hidden");
|
|
2359
|
+
flash("Terminal connection lost", "error");
|
|
2360
|
+
}
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* Disconnect from terminal
|
|
2366
|
+
*/
|
|
2367
|
+
disconnect() {
|
|
2368
|
+
this.intentionalDisconnect = true;
|
|
2369
|
+
if (this.reconnectTimer) {
|
|
2370
|
+
clearTimeout(this.reconnectTimer);
|
|
2371
|
+
this.reconnectTimer = null;
|
|
2372
|
+
}
|
|
2373
|
+
this._teardownSocket();
|
|
2374
|
+
this._setStatus("Disconnected");
|
|
2375
|
+
this.overlayEl?.classList.remove("hidden");
|
|
2376
|
+
this._updateButtons(false);
|
|
2377
|
+
|
|
2378
|
+
if (this.voiceKeyActive) {
|
|
2379
|
+
this.voiceKeyActive = false;
|
|
2380
|
+
this.voiceController?.stop();
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// ==================== TEXT INPUT PANEL ====================
|
|
2385
|
+
|
|
2386
|
+
_readBoolFromStorage(key, fallback) {
|
|
2387
|
+
const raw = localStorage.getItem(key);
|
|
2388
|
+
if (raw === null) return fallback;
|
|
2389
|
+
if (raw === "1" || raw === "true") return true;
|
|
2390
|
+
if (raw === "0" || raw === "false") return false;
|
|
2391
|
+
return fallback;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
_writeBoolToStorage(key, value) {
|
|
2395
|
+
localStorage.setItem(key, value ? "1" : "0");
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
_safeFocus(el) {
|
|
2399
|
+
if (!el) return;
|
|
2400
|
+
try {
|
|
2401
|
+
el.focus({ preventScroll: true });
|
|
2402
|
+
} catch (err) {
|
|
2403
|
+
try {
|
|
2404
|
+
el.focus();
|
|
2405
|
+
} catch (_err) {
|
|
2406
|
+
// ignore
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
_captureTextInputSelection() {
|
|
2412
|
+
if (!this.textInputTextareaEl) return;
|
|
2413
|
+
if (document.activeElement !== this.textInputTextareaEl) return;
|
|
2414
|
+
const start = Number.isInteger(this.textInputTextareaEl.selectionStart)
|
|
2415
|
+
? this.textInputTextareaEl.selectionStart
|
|
2416
|
+
: null;
|
|
2417
|
+
const end = Number.isInteger(this.textInputTextareaEl.selectionEnd)
|
|
2418
|
+
? this.textInputTextareaEl.selectionEnd
|
|
2419
|
+
: null;
|
|
2420
|
+
if (start === null || end === null) return;
|
|
2421
|
+
this.textInputSelection = { start, end };
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
_getTextInputSelection() {
|
|
2425
|
+
if (!this.textInputTextareaEl) return { start: 0, end: 0 };
|
|
2426
|
+
const textarea = this.textInputTextareaEl;
|
|
2427
|
+
const value = textarea.value || "";
|
|
2428
|
+
const max = value.length;
|
|
2429
|
+
const focused = document.activeElement === textarea;
|
|
2430
|
+
let start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : null;
|
|
2431
|
+
let end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : null;
|
|
2432
|
+
|
|
2433
|
+
if (!focused || start === null || end === null) {
|
|
2434
|
+
if (
|
|
2435
|
+
Number.isInteger(this.textInputSelection.start) &&
|
|
2436
|
+
Number.isInteger(this.textInputSelection.end)
|
|
2437
|
+
) {
|
|
2438
|
+
start = this.textInputSelection.start;
|
|
2439
|
+
end = this.textInputSelection.end;
|
|
2440
|
+
} else {
|
|
2441
|
+
start = max;
|
|
2442
|
+
end = max;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
start = Math.min(Math.max(0, start ?? 0), max);
|
|
2447
|
+
end = Math.min(Math.max(0, end ?? 0), max);
|
|
2448
|
+
if (end < start) end = start;
|
|
2449
|
+
return { start, end };
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
_normalizeNewlines(text) {
|
|
2453
|
+
return (text || "").replace(/\r\n?/g, "\n");
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
_makeTextInputId() {
|
|
2457
|
+
return (
|
|
2458
|
+
(window.crypto &&
|
|
2459
|
+
typeof window.crypto.randomUUID === "function" &&
|
|
2460
|
+
window.crypto.randomUUID()) ||
|
|
2461
|
+
`${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
_splitTextByBytes(text, maxBytes) {
|
|
2466
|
+
const chunkLimit = Math.max(
|
|
2467
|
+
4,
|
|
2468
|
+
Number.isFinite(maxBytes) ? maxBytes : TEXT_INPUT_SIZE_LIMITS.chunkBytes
|
|
2469
|
+
);
|
|
2470
|
+
const chunks = [];
|
|
2471
|
+
let totalBytes = 0;
|
|
2472
|
+
let chunkBytes = 0;
|
|
2473
|
+
let chunkParts = [];
|
|
2474
|
+
|
|
2475
|
+
for (let i = 0; i < text.length; ) {
|
|
2476
|
+
const codePoint = text.codePointAt(i);
|
|
2477
|
+
const charLen = codePoint > 0xffff ? 2 : 1;
|
|
2478
|
+
const charBytes =
|
|
2479
|
+
codePoint <= 0x7f
|
|
2480
|
+
? 1
|
|
2481
|
+
: codePoint <= 0x7ff
|
|
2482
|
+
? 2
|
|
2483
|
+
: codePoint <= 0xffff
|
|
2484
|
+
? 3
|
|
2485
|
+
: 4;
|
|
2486
|
+
|
|
2487
|
+
if (chunkBytes + charBytes > chunkLimit && chunkParts.length) {
|
|
2488
|
+
chunks.push(chunkParts.join(""));
|
|
2489
|
+
chunkParts = [];
|
|
2490
|
+
chunkBytes = 0;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
chunkParts.push(text.slice(i, i + charLen));
|
|
2494
|
+
chunkBytes += charBytes;
|
|
2495
|
+
totalBytes += charBytes;
|
|
2496
|
+
i += charLen;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
if (chunkParts.length) {
|
|
2500
|
+
chunks.push(chunkParts.join(""));
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
return { chunks, totalBytes };
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
_updateTextInputSendUi() {
|
|
2507
|
+
if (!this.textInputSendBtn) return;
|
|
2508
|
+
const connected = Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
|
|
2509
|
+
const pending = Boolean(this.textInputPending);
|
|
2510
|
+
this.textInputSendBtn.disabled = this.sessionNotFound && !connected;
|
|
2511
|
+
const ariaDisabled = this.textInputSendBtn.disabled || !connected;
|
|
2512
|
+
this.textInputSendBtn.setAttribute("aria-disabled", ariaDisabled ? "true" : "false");
|
|
2513
|
+
this.textInputSendBtn.classList.toggle("disconnected", !connected);
|
|
2514
|
+
this.textInputSendBtn.classList.toggle("pending", pending);
|
|
2515
|
+
if (this.textInputSendBtnLabel === null) {
|
|
2516
|
+
this.textInputSendBtnLabel = this.textInputSendBtn.textContent || "Send";
|
|
2517
|
+
}
|
|
2518
|
+
this.textInputSendBtn.textContent = pending ? "Sending…" : this.textInputSendBtnLabel;
|
|
2519
|
+
|
|
2520
|
+
const hintEl = document.getElementById("terminal-text-hint");
|
|
2521
|
+
if (!hintEl) return;
|
|
2522
|
+
if (this.textInputHintBase === null) {
|
|
2523
|
+
this.textInputHintBase = hintEl.textContent || "";
|
|
2524
|
+
}
|
|
2525
|
+
if (pending) {
|
|
2526
|
+
hintEl.textContent = "Sending… Your text will stay here until confirmed.";
|
|
2527
|
+
} else if (this.sessionNotFound && !connected) {
|
|
2528
|
+
hintEl.textContent = "Session expired. Click New or Resume to reconnect.";
|
|
2529
|
+
} else {
|
|
2530
|
+
hintEl.textContent = this.textInputHintBase;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
_persistTextInputDraft() {
|
|
2535
|
+
if (!this.textInputTextareaEl) return;
|
|
2536
|
+
try {
|
|
2537
|
+
localStorage.setItem(TEXT_INPUT_STORAGE_KEYS.draft, this.textInputTextareaEl.value || "");
|
|
2538
|
+
} catch (_err) {
|
|
2539
|
+
// ignore
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
_restoreTextInputDraft() {
|
|
2544
|
+
if (!this.textInputTextareaEl) return;
|
|
2545
|
+
if (this.textInputTextareaEl.value) return;
|
|
2546
|
+
try {
|
|
2547
|
+
const draft = localStorage.getItem(TEXT_INPUT_STORAGE_KEYS.draft);
|
|
2548
|
+
if (draft) this.textInputTextareaEl.value = draft;
|
|
2549
|
+
} catch (_err) {
|
|
2550
|
+
// ignore
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
_loadPendingTextInput() {
|
|
2555
|
+
try {
|
|
2556
|
+
const raw = localStorage.getItem(TEXT_INPUT_STORAGE_KEYS.pending);
|
|
2557
|
+
if (!raw) return null;
|
|
2558
|
+
const parsed = JSON.parse(raw);
|
|
2559
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
2560
|
+
if (typeof parsed.id !== "string" || typeof parsed.payload !== "string") return null;
|
|
2561
|
+
if (typeof parsed.originalText !== "string") return null;
|
|
2562
|
+
if (parsed.sendEnter !== undefined && typeof parsed.sendEnter !== "boolean") return null;
|
|
2563
|
+
const pending = {
|
|
2564
|
+
id: parsed.id,
|
|
2565
|
+
payload: parsed.payload,
|
|
2566
|
+
originalText: parsed.originalText,
|
|
2567
|
+
sentAt: typeof parsed.sentAt === "number" ? parsed.sentAt : Date.now(),
|
|
2568
|
+
lastRetryAt: typeof parsed.lastRetryAt === "number" ? parsed.lastRetryAt : null,
|
|
2569
|
+
sendEnter: parsed.sendEnter === true,
|
|
2570
|
+
chunkSize:
|
|
2571
|
+
Number.isFinite(parsed.chunkSize) && parsed.chunkSize > 0
|
|
2572
|
+
? parsed.chunkSize
|
|
2573
|
+
: TEXT_INPUT_SIZE_LIMITS.chunkBytes,
|
|
2574
|
+
chunkIndex: Number.isInteger(parsed.chunkIndex) ? parsed.chunkIndex : 0,
|
|
2575
|
+
chunkIds: Array.isArray(parsed.chunkIds)
|
|
2576
|
+
? parsed.chunkIds.filter((id) => typeof id === "string")
|
|
2577
|
+
: null,
|
|
2578
|
+
inFlightId: typeof parsed.inFlightId === "string" ? parsed.inFlightId : null,
|
|
2579
|
+
totalBytes: Number.isFinite(parsed.totalBytes) ? parsed.totalBytes : null,
|
|
2580
|
+
};
|
|
2581
|
+
if (pending.chunkIndex < 0) pending.chunkIndex = 0;
|
|
2582
|
+
if (pending.chunkIds && pending.chunkIds.length === 0) pending.chunkIds = null;
|
|
2583
|
+
return pending;
|
|
2584
|
+
} catch (_err) {
|
|
2585
|
+
return null;
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
_savePendingTextInput(pending) {
|
|
2590
|
+
try {
|
|
2591
|
+
localStorage.setItem(TEXT_INPUT_STORAGE_KEYS.pending, JSON.stringify(pending));
|
|
2592
|
+
} catch (_err) {
|
|
2593
|
+
// ignore
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
_queuePendingTextInput(payload, originalText, options = {}) {
|
|
2598
|
+
const sendEnter = Boolean(options.sendEnter);
|
|
2599
|
+
const { chunks, totalBytes } = this._splitTextByBytes(
|
|
2600
|
+
payload,
|
|
2601
|
+
TEXT_INPUT_SIZE_LIMITS.chunkBytes
|
|
2602
|
+
);
|
|
2603
|
+
const chunkIds = chunks.map(() => this._makeTextInputId());
|
|
2604
|
+
const id = this._makeTextInputId();
|
|
2605
|
+
|
|
2606
|
+
this.textInputPendingChunks = chunks;
|
|
2607
|
+
this.textInputPending = {
|
|
2608
|
+
id,
|
|
2609
|
+
payload,
|
|
2610
|
+
originalText,
|
|
2611
|
+
sentAt: Date.now(),
|
|
2612
|
+
lastRetryAt: null,
|
|
2613
|
+
sendEnter,
|
|
2614
|
+
chunkIndex: 0,
|
|
2615
|
+
chunkIds,
|
|
2616
|
+
chunkSize: TEXT_INPUT_SIZE_LIMITS.chunkBytes,
|
|
2617
|
+
inFlightId: null,
|
|
2618
|
+
totalBytes,
|
|
2619
|
+
};
|
|
2620
|
+
this._savePendingTextInput(this.textInputPending);
|
|
2621
|
+
this._updateTextInputSendUi();
|
|
2622
|
+
return id;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
_clearPendingTextInput() {
|
|
2626
|
+
this.textInputPending = null;
|
|
2627
|
+
this.textInputPendingChunks = null;
|
|
2628
|
+
try {
|
|
2629
|
+
localStorage.removeItem(TEXT_INPUT_STORAGE_KEYS.pending);
|
|
2630
|
+
} catch (_err) {
|
|
2631
|
+
// ignore
|
|
2632
|
+
}
|
|
2633
|
+
this._updateTextInputSendUi();
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
_ensurePendingTextInputChunks() {
|
|
2637
|
+
if (!this.textInputPending) return null;
|
|
2638
|
+
if (Array.isArray(this.textInputPendingChunks) && this.textInputPendingChunks.length) {
|
|
2639
|
+
return this.textInputPendingChunks;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
const pending = this.textInputPending;
|
|
2643
|
+
const chunkSize =
|
|
2644
|
+
Number.isFinite(pending.chunkSize) && pending.chunkSize > 0
|
|
2645
|
+
? pending.chunkSize
|
|
2646
|
+
: TEXT_INPUT_SIZE_LIMITS.chunkBytes;
|
|
2647
|
+
const { chunks, totalBytes } = this._splitTextByBytes(pending.payload || "", chunkSize);
|
|
2648
|
+
if (!chunks.length) {
|
|
2649
|
+
this._clearPendingTextInput();
|
|
2650
|
+
return null;
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
this.textInputPendingChunks = chunks;
|
|
2654
|
+
if (!Array.isArray(pending.chunkIds) || pending.chunkIds.length !== chunks.length) {
|
|
2655
|
+
pending.chunkIds = chunks.map(() => this._makeTextInputId());
|
|
2656
|
+
}
|
|
2657
|
+
if (!Number.isInteger(pending.chunkIndex) || pending.chunkIndex < 0) {
|
|
2658
|
+
pending.chunkIndex = 0;
|
|
2659
|
+
}
|
|
2660
|
+
if (pending.chunkIndex >= chunks.length) {
|
|
2661
|
+
pending.chunkIndex = Math.max(0, chunks.length - 1);
|
|
2662
|
+
}
|
|
2663
|
+
if (
|
|
2664
|
+
pending.inFlightId &&
|
|
2665
|
+
(!Array.isArray(pending.chunkIds) || !pending.chunkIds.includes(pending.inFlightId))
|
|
2666
|
+
) {
|
|
2667
|
+
pending.inFlightId = null;
|
|
2668
|
+
}
|
|
2669
|
+
pending.totalBytes = totalBytes;
|
|
2670
|
+
this._savePendingTextInput(pending);
|
|
2671
|
+
return chunks;
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
_sendPendingTextInputChunk() {
|
|
2675
|
+
if (!this.textInputPending) return false;
|
|
2676
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false;
|
|
2677
|
+
|
|
2678
|
+
const chunks = this._ensurePendingTextInputChunks();
|
|
2679
|
+
if (!chunks || !chunks.length) return false;
|
|
2680
|
+
|
|
2681
|
+
const pending = this.textInputPending;
|
|
2682
|
+
const index = Number.isInteger(pending.chunkIndex) ? pending.chunkIndex : 0;
|
|
2683
|
+
if (index >= chunks.length) {
|
|
2684
|
+
this._clearPendingTextInput();
|
|
2685
|
+
return false;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
const chunkId =
|
|
2689
|
+
pending.inFlightId ||
|
|
2690
|
+
(Array.isArray(pending.chunkIds) ? pending.chunkIds[index] : null) ||
|
|
2691
|
+
this._makeTextInputId();
|
|
2692
|
+
pending.inFlightId = chunkId;
|
|
2693
|
+
if (Array.isArray(pending.chunkIds)) {
|
|
2694
|
+
pending.chunkIds[index] = chunkId;
|
|
2695
|
+
} else {
|
|
2696
|
+
pending.chunkIds = [chunkId];
|
|
2697
|
+
}
|
|
2698
|
+
this._savePendingTextInput(pending);
|
|
2699
|
+
|
|
2700
|
+
try {
|
|
2701
|
+
this.socket.send(
|
|
2702
|
+
JSON.stringify({
|
|
2703
|
+
type: "input",
|
|
2704
|
+
id: chunkId,
|
|
2705
|
+
data: chunks[index],
|
|
2706
|
+
})
|
|
2707
|
+
);
|
|
2708
|
+
this._markSessionActive();
|
|
2709
|
+
return true;
|
|
2710
|
+
} catch (_err) {
|
|
2711
|
+
return false;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
_handleTextInputAck(payload) {
|
|
2716
|
+
if (!this.textInputPending || !payload) return false;
|
|
2717
|
+
const ackId = payload.id;
|
|
2718
|
+
if (!ackId || typeof ackId !== "string") return false;
|
|
2719
|
+
|
|
2720
|
+
const chunks = this._ensurePendingTextInputChunks();
|
|
2721
|
+
if (!chunks || !chunks.length) return false;
|
|
2722
|
+
|
|
2723
|
+
const pending = this.textInputPending;
|
|
2724
|
+
const index = Number.isInteger(pending.chunkIndex) ? pending.chunkIndex : 0;
|
|
2725
|
+
const expectedId =
|
|
2726
|
+
pending.inFlightId ||
|
|
2727
|
+
(Array.isArray(pending.chunkIds) ? pending.chunkIds[index] : null);
|
|
2728
|
+
if (ackId !== expectedId) return false;
|
|
2729
|
+
|
|
2730
|
+
if (payload.ok === false) {
|
|
2731
|
+
flash(payload.message || "Send failed; your text is preserved", "error");
|
|
2732
|
+
this._updateTextInputSendUi();
|
|
2733
|
+
return true;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
pending.inFlightId = null;
|
|
2737
|
+
pending.chunkIndex = index + 1;
|
|
2738
|
+
this._savePendingTextInput(pending);
|
|
2739
|
+
|
|
2740
|
+
if (pending.chunkIndex >= chunks.length) {
|
|
2741
|
+
const shouldSendEnter = pending.sendEnter;
|
|
2742
|
+
const current = this.textInputTextareaEl?.value || "";
|
|
2743
|
+
if (current === pending.originalText) {
|
|
2744
|
+
if (this.textInputTextareaEl) {
|
|
2745
|
+
this.textInputTextareaEl.value = "";
|
|
2746
|
+
this._persistTextInputDraft();
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
if (shouldSendEnter) {
|
|
2750
|
+
this._sendEnterForTextInput();
|
|
2751
|
+
}
|
|
2752
|
+
this._clearPendingTextInput();
|
|
2753
|
+
return true;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
this._sendPendingTextInputChunk();
|
|
2757
|
+
return true;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
_sendText(text, options = {}) {
|
|
2761
|
+
const appendNewline = Boolean(options.appendNewline);
|
|
2762
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
2763
|
+
flash("Connect the terminal first", "error");
|
|
2764
|
+
return false;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
let payload = this._normalizeNewlines(text);
|
|
2768
|
+
if (!payload) return false;
|
|
2769
|
+
|
|
2770
|
+
if (appendNewline && !payload.endsWith("\n")) {
|
|
2771
|
+
payload = `${payload}\n`;
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
const { chunks, totalBytes } = this._splitTextByBytes(
|
|
2775
|
+
payload,
|
|
2776
|
+
TEXT_INPUT_SIZE_LIMITS.chunkBytes
|
|
2777
|
+
);
|
|
2778
|
+
if (!chunks.length) return false;
|
|
2779
|
+
if (totalBytes > TEXT_INPUT_SIZE_LIMITS.warnBytes) {
|
|
2780
|
+
const chunkNote = chunks.length > 1 ? ` in ${chunks.length} chunks` : "";
|
|
2781
|
+
flash(
|
|
2782
|
+
`Large paste (${Math.round(totalBytes / 1024)}KB); sending${chunkNote} may be slow.`,
|
|
2783
|
+
"info"
|
|
2784
|
+
);
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
this._markSessionActive();
|
|
2788
|
+
for (const chunk of chunks) {
|
|
2789
|
+
this.socket.send(textEncoder.encode(chunk));
|
|
2790
|
+
}
|
|
2791
|
+
return true;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
_sendEnterForTextInput() {
|
|
2795
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
|
2796
|
+
this._markSessionActive();
|
|
2797
|
+
this.socket.send(textEncoder.encode("\r"));
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
_sendTextWithAck(text, options = {}) {
|
|
2801
|
+
const appendNewline = Boolean(options.appendNewline);
|
|
2802
|
+
const sendEnter = Boolean(options.sendEnter);
|
|
2803
|
+
|
|
2804
|
+
let payload = this._normalizeNewlines(text);
|
|
2805
|
+
if (!payload) return false;
|
|
2806
|
+
|
|
2807
|
+
const originalText =
|
|
2808
|
+
typeof options.originalText === "string"
|
|
2809
|
+
? this._normalizeNewlines(options.originalText)
|
|
2810
|
+
: payload;
|
|
2811
|
+
if (appendNewline && !payload.endsWith("\n")) {
|
|
2812
|
+
payload = `${payload}\n`;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
const socketOpen = Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
|
|
2816
|
+
this._queuePendingTextInput(payload, originalText, { sendEnter });
|
|
2817
|
+
|
|
2818
|
+
const totalBytes = this.textInputPending?.totalBytes || 0;
|
|
2819
|
+
const chunkCount = this.textInputPendingChunks?.length || 0;
|
|
2820
|
+
if (totalBytes > TEXT_INPUT_SIZE_LIMITS.warnBytes) {
|
|
2821
|
+
const chunkNote = chunkCount > 1 ? ` in ${chunkCount} chunks` : "";
|
|
2822
|
+
flash(
|
|
2823
|
+
`Large paste (${Math.round(totalBytes / 1024)}KB); sending${chunkNote} may be slow.`,
|
|
2824
|
+
"info"
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
if (!socketOpen) {
|
|
2829
|
+
const savedSessionId = this._getSavedSessionId();
|
|
2830
|
+
if (!this.socket || this.socket.readyState !== WebSocket.CONNECTING) {
|
|
2831
|
+
if (savedSessionId) {
|
|
2832
|
+
this.connect({ mode: "attach", quiet: true });
|
|
2833
|
+
} else {
|
|
2834
|
+
this.connect({ mode: "new", quiet: true });
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
return true;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
if (!this._sendPendingTextInputChunk()) {
|
|
2841
|
+
flash("Send failed; your text is preserved", "error");
|
|
2842
|
+
this._updateTextInputSendUi();
|
|
2843
|
+
return false;
|
|
2844
|
+
}
|
|
2845
|
+
return true;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
_retryPendingTextInput() {
|
|
2849
|
+
if (!this.textInputPending) return;
|
|
2850
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
2851
|
+
const savedSessionId = this._getSavedSessionId();
|
|
2852
|
+
if (!this.socket || this.socket.readyState !== WebSocket.CONNECTING) {
|
|
2853
|
+
if (savedSessionId) {
|
|
2854
|
+
this.connect({ mode: "attach", quiet: true });
|
|
2855
|
+
} else {
|
|
2856
|
+
this.connect({ mode: "new", quiet: true });
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
flash("Reconnecting to resend pending input…", "info");
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
const now = Date.now();
|
|
2863
|
+
const lastRetryAt = this.textInputPending.lastRetryAt || 0;
|
|
2864
|
+
if (now - lastRetryAt < 1500) {
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
this.textInputPending.lastRetryAt = now;
|
|
2868
|
+
this._savePendingTextInput(this.textInputPending);
|
|
2869
|
+
if (this._sendPendingTextInputChunk()) {
|
|
2870
|
+
flash("Retrying send…", "info");
|
|
2871
|
+
} else {
|
|
2872
|
+
flash("Retry failed; your text is preserved", "error");
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
_setTextInputEnabled(enabled, options = {}) {
|
|
2877
|
+
this.textInputEnabled = Boolean(enabled);
|
|
2878
|
+
this._writeBoolToStorage(TEXT_INPUT_STORAGE_KEYS.enabled, this.textInputEnabled);
|
|
2879
|
+
publish("terminal:compose", { open: this.textInputEnabled });
|
|
2880
|
+
|
|
2881
|
+
const focus = options.focus !== false;
|
|
2882
|
+
const shouldFocusTextarea = focus && (this.isTouchDevice() || options.focusTextarea);
|
|
2883
|
+
|
|
2884
|
+
this.textInputToggleBtn?.setAttribute(
|
|
2885
|
+
"aria-expanded",
|
|
2886
|
+
this.textInputEnabled ? "true" : "false"
|
|
2887
|
+
);
|
|
2888
|
+
this.textInputPanelEl?.classList.toggle("hidden", !this.textInputEnabled);
|
|
2889
|
+
this.textInputPanelEl?.setAttribute(
|
|
2890
|
+
"aria-hidden",
|
|
2891
|
+
this.textInputEnabled ? "false" : "true"
|
|
2892
|
+
);
|
|
2893
|
+
this.terminalSectionEl?.classList.toggle("text-input-open", this.textInputEnabled);
|
|
2894
|
+
this._updateComposerSticky();
|
|
2895
|
+
|
|
2896
|
+
// The panel changes the terminal container height via CSS; refit xterm
|
|
2897
|
+
this._captureTerminalScrollState();
|
|
2898
|
+
this.deferScrollRestore = true;
|
|
2899
|
+
this._scheduleResizeAfterLayout();
|
|
2900
|
+
|
|
2901
|
+
if (this.textInputEnabled && shouldFocusTextarea) {
|
|
2902
|
+
requestAnimationFrame(() => {
|
|
2903
|
+
this._safeFocus(this.textInputTextareaEl);
|
|
2904
|
+
});
|
|
2905
|
+
} else if (!this.isTouchDevice()) {
|
|
2906
|
+
this.term?.focus();
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
_updateTextInputConnected() {
|
|
2911
|
+
if (this.textInputTextareaEl) this.textInputTextareaEl.disabled = false;
|
|
2912
|
+
this._updateTextInputSendUi();
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
async _sendFromTextarea() {
|
|
2916
|
+
const text = this.textInputTextareaEl?.value || "";
|
|
2917
|
+
const normalized = this._normalizeNewlines(text);
|
|
2918
|
+
if (this.textInputPending) {
|
|
2919
|
+
if (normalized && normalized !== this.textInputPending.originalText) {
|
|
2920
|
+
// New draft should be sendable even if a previous payload is pending.
|
|
2921
|
+
this._clearPendingTextInput();
|
|
2922
|
+
} else {
|
|
2923
|
+
this._retryPendingTextInput();
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
this._persistTextInputDraft();
|
|
2928
|
+
if (this.textInputHookInFlight) {
|
|
2929
|
+
flash("Send already in progress", "error");
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
this.textInputHookInFlight = true;
|
|
2933
|
+
let payload = normalized;
|
|
2934
|
+
try {
|
|
2935
|
+
payload = await this._applyTextInputHooksAsync(normalized);
|
|
2936
|
+
} finally {
|
|
2937
|
+
this.textInputHookInFlight = false;
|
|
2938
|
+
}
|
|
2939
|
+
const needsEnter = Boolean(payload && !payload.endsWith("\n"));
|
|
2940
|
+
const ok = this._sendTextWithAck(payload, {
|
|
2941
|
+
appendNewline: false,
|
|
2942
|
+
sendEnter: needsEnter,
|
|
2943
|
+
originalText: normalized,
|
|
2944
|
+
});
|
|
2945
|
+
if (!ok) return;
|
|
2946
|
+
this._scrollToBottomIfNearBottom();
|
|
2947
|
+
|
|
2948
|
+
if (this.isTouchDevice()) {
|
|
2949
|
+
requestAnimationFrame(() => {
|
|
2950
|
+
this._safeFocus(this.textInputTextareaEl);
|
|
2951
|
+
});
|
|
2952
|
+
} else {
|
|
2953
|
+
this.term?.focus();
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
_insertTextIntoTextInput(text, options = {}) {
|
|
2958
|
+
if (!text) return false;
|
|
2959
|
+
if (!this.textInputTextareaEl) return false;
|
|
2960
|
+
|
|
2961
|
+
if (!this.textInputEnabled) {
|
|
2962
|
+
this._setTextInputEnabled(true, { focus: true, focusTextarea: true });
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
const textarea = this.textInputTextareaEl;
|
|
2966
|
+
const value = textarea.value || "";
|
|
2967
|
+
const replaceSelection = options.replaceSelection !== false;
|
|
2968
|
+
const selection = this._getTextInputSelection();
|
|
2969
|
+
const insertAt = replaceSelection ? selection.start : selection.end;
|
|
2970
|
+
const prefix = value.slice(0, insertAt);
|
|
2971
|
+
const suffix = value.slice(replaceSelection ? selection.end : insertAt);
|
|
2972
|
+
|
|
2973
|
+
let insert = String(text);
|
|
2974
|
+
if (options.separator === "newline") {
|
|
2975
|
+
insert = `${prefix && !prefix.endsWith("\n") ? "\n" : ""}${insert}`;
|
|
2976
|
+
} else if (options.separator === "space") {
|
|
2977
|
+
insert = `${prefix && !/\s$/.test(prefix) ? " " : ""}${insert}`;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
textarea.value = `${prefix}${insert}${suffix}`;
|
|
2981
|
+
const cursor = prefix.length + insert.length;
|
|
2982
|
+
textarea.setSelectionRange(cursor, cursor);
|
|
2983
|
+
this.textInputSelection = { start: cursor, end: cursor };
|
|
2984
|
+
this._persistTextInputDraft();
|
|
2985
|
+
this._updateComposerSticky();
|
|
2986
|
+
this._safeFocus(textarea);
|
|
2987
|
+
return true;
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
async _uploadTerminalImage(file) {
|
|
2991
|
+
if (!file) return;
|
|
2992
|
+
const fileName = (file.name || "").toLowerCase();
|
|
2993
|
+
const looksLikeImage =
|
|
2994
|
+
(file.type && file.type.startsWith("image/")) ||
|
|
2995
|
+
/\.(png|jpe?g|gif|webp|heic|heif)$/.test(fileName);
|
|
2996
|
+
if (!looksLikeImage) {
|
|
2997
|
+
flash("That file is not an image", "error");
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
const formData = new FormData();
|
|
3002
|
+
formData.append("file", file, file.name || "image");
|
|
3003
|
+
|
|
3004
|
+
if (this.textInputImageBtn) {
|
|
3005
|
+
this.textInputImageBtn.disabled = true;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
try {
|
|
3009
|
+
const response = await api(CONSTANTS.API.TERMINAL_IMAGE_ENDPOINT, {
|
|
3010
|
+
method: "POST",
|
|
3011
|
+
body: formData,
|
|
3012
|
+
});
|
|
3013
|
+
const imagePath = response?.path || response?.abs_path;
|
|
3014
|
+
if (!imagePath) {
|
|
3015
|
+
throw new Error("Upload returned no path");
|
|
3016
|
+
}
|
|
3017
|
+
this._insertTextIntoTextInput(imagePath, {
|
|
3018
|
+
separator: "newline",
|
|
3019
|
+
replaceSelection: false,
|
|
3020
|
+
});
|
|
3021
|
+
flash(`Image saved to ${imagePath}`);
|
|
3022
|
+
} catch (err) {
|
|
3023
|
+
const message = err?.message ? String(err.message) : "Image upload failed";
|
|
3024
|
+
flash(message, "error");
|
|
3025
|
+
} finally {
|
|
3026
|
+
if (this.textInputImageBtn) {
|
|
3027
|
+
this.textInputImageBtn.disabled = false;
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
async _handleImageFiles(files) {
|
|
3033
|
+
if (!files || files.length === 0) return;
|
|
3034
|
+
const images = Array.from(files).filter((file) => {
|
|
3035
|
+
if (!file) return false;
|
|
3036
|
+
if (file.type && file.type.startsWith("image/")) return true;
|
|
3037
|
+
const fileName = (file.name || "").toLowerCase();
|
|
3038
|
+
return /\.(png|jpe?g|gif|webp|heic|heif)$/.test(fileName);
|
|
3039
|
+
});
|
|
3040
|
+
if (!images.length) {
|
|
3041
|
+
flash("No image found in clipboard", "error");
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
for (const file of images) {
|
|
3045
|
+
await this._uploadTerminalImage(file);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
_initTextInputPanel() {
|
|
3050
|
+
this.terminalSectionEl = document.getElementById("terminal");
|
|
3051
|
+
this.textInputToggleBtn = document.getElementById("terminal-text-input-toggle");
|
|
3052
|
+
this.textInputPanelEl = document.getElementById("terminal-text-input");
|
|
3053
|
+
this.textInputTextareaEl = document.getElementById("terminal-textarea");
|
|
3054
|
+
this.textInputSendBtn = document.getElementById("terminal-text-send");
|
|
3055
|
+
this.textInputImageBtn = document.getElementById("terminal-text-image");
|
|
3056
|
+
this.textInputImageInputEl = document.getElementById("terminal-text-image-input");
|
|
3057
|
+
|
|
3058
|
+
if (this.textInputSendBtn) {
|
|
3059
|
+
console.log("TerminalManager: initialized send button");
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
if (
|
|
3063
|
+
!this.terminalSectionEl ||
|
|
3064
|
+
!this.textInputToggleBtn ||
|
|
3065
|
+
!this.textInputPanelEl ||
|
|
3066
|
+
!this.textInputTextareaEl ||
|
|
3067
|
+
!this.textInputSendBtn
|
|
3068
|
+
) {
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
this.textInputEnabled = this._readBoolFromStorage(
|
|
3073
|
+
TEXT_INPUT_STORAGE_KEYS.enabled,
|
|
3074
|
+
this.isTouchDevice()
|
|
3075
|
+
);
|
|
3076
|
+
|
|
3077
|
+
this.textInputToggleBtn.addEventListener("click", () => {
|
|
3078
|
+
this._setTextInputEnabled(!this.textInputEnabled, { focus: true, focusTextarea: true });
|
|
3079
|
+
});
|
|
3080
|
+
|
|
3081
|
+
const triggerSend = async () => {
|
|
3082
|
+
if (this.textInputSendBtn?.disabled) {
|
|
3083
|
+
flash("Connect the terminal first", "error");
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
const now = Date.now();
|
|
3087
|
+
// Debounce to prevent double-firing from touch+click or rapid taps
|
|
3088
|
+
if (now - this.lastSendTapAt < 300) return;
|
|
3089
|
+
this.lastSendTapAt = now;
|
|
3090
|
+
console.log("TerminalManager: sending text input");
|
|
3091
|
+
await this._sendFromTextarea();
|
|
3092
|
+
};
|
|
3093
|
+
this.textInputSendBtn.addEventListener("pointerup", (e) => {
|
|
3094
|
+
if (e.pointerType !== "touch") return;
|
|
3095
|
+
if (e.cancelable) e.preventDefault();
|
|
3096
|
+
this.suppressNextSendClick = true;
|
|
3097
|
+
triggerSend();
|
|
3098
|
+
});
|
|
3099
|
+
this.textInputSendBtn.addEventListener("touchend", (e) => {
|
|
3100
|
+
if (e.cancelable) e.preventDefault();
|
|
3101
|
+
this.suppressNextSendClick = true;
|
|
3102
|
+
triggerSend();
|
|
3103
|
+
});
|
|
3104
|
+
this.textInputSendBtn.addEventListener("click", () => {
|
|
3105
|
+
if (this.suppressNextSendClick) {
|
|
3106
|
+
this.suppressNextSendClick = false;
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
triggerSend();
|
|
3110
|
+
});
|
|
3111
|
+
|
|
3112
|
+
this.textInputTextareaEl.addEventListener("input", () => {
|
|
3113
|
+
this._persistTextInputDraft();
|
|
3114
|
+
this._updateComposerSticky();
|
|
3115
|
+
this._captureTextInputSelection();
|
|
3116
|
+
});
|
|
3117
|
+
|
|
3118
|
+
this.textInputTextareaEl.addEventListener("keydown", (e) => {
|
|
3119
|
+
if (e.key !== "Enter" || e.isComposing) return;
|
|
3120
|
+
const shouldSend = e.metaKey || e.ctrlKey;
|
|
3121
|
+
if (shouldSend) {
|
|
3122
|
+
e.preventDefault();
|
|
3123
|
+
triggerSend();
|
|
3124
|
+
}
|
|
3125
|
+
e.stopPropagation();
|
|
3126
|
+
});
|
|
3127
|
+
|
|
3128
|
+
const captureSelection = () => this._captureTextInputSelection();
|
|
3129
|
+
this.textInputTextareaEl.addEventListener("select", captureSelection);
|
|
3130
|
+
this.textInputTextareaEl.addEventListener("keyup", captureSelection);
|
|
3131
|
+
this.textInputTextareaEl.addEventListener("mouseup", captureSelection);
|
|
3132
|
+
this.textInputTextareaEl.addEventListener("touchend", captureSelection);
|
|
3133
|
+
|
|
3134
|
+
if (this.textInputImageBtn && this.textInputImageInputEl) {
|
|
3135
|
+
this.textInputTextareaEl.addEventListener("paste", (e) => {
|
|
3136
|
+
const items = e.clipboardData?.items;
|
|
3137
|
+
if (!items || !items.length) return;
|
|
3138
|
+
const files = [];
|
|
3139
|
+
for (const item of items) {
|
|
3140
|
+
if (item.type && item.type.startsWith("image/")) {
|
|
3141
|
+
const file = item.getAsFile();
|
|
3142
|
+
if (file) files.push(file);
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
if (!files.length) return;
|
|
3146
|
+
e.preventDefault();
|
|
3147
|
+
this._handleImageFiles(files);
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
this.textInputImageBtn.addEventListener("click", () => {
|
|
3151
|
+
this._captureTextInputSelection();
|
|
3152
|
+
this.textInputImageInputEl?.click();
|
|
3153
|
+
});
|
|
3154
|
+
|
|
3155
|
+
this.textInputImageInputEl.addEventListener("change", () => {
|
|
3156
|
+
const files = Array.from(this.textInputImageInputEl?.files || []);
|
|
3157
|
+
if (!files.length) return;
|
|
3158
|
+
this._handleImageFiles(files);
|
|
3159
|
+
this.textInputImageInputEl.value = "";
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
this.textInputTextareaEl.addEventListener("focus", () => {
|
|
3164
|
+
this.textInputWasFocused = true;
|
|
3165
|
+
this._updateComposerSticky();
|
|
3166
|
+
this._updateViewportInsets();
|
|
3167
|
+
this._captureTextInputSelection();
|
|
3168
|
+
this._captureTerminalScrollState();
|
|
3169
|
+
this.deferScrollRestore = true;
|
|
3170
|
+
if (this.isTouchDevice() && isMobileViewport()) {
|
|
3171
|
+
// Enter the mobile scroll-only view when composing; keep the real TUI visible
|
|
3172
|
+
// only when the user is not focused on the text input.
|
|
3173
|
+
this._scheduleResizeAfterLayout();
|
|
3174
|
+
this._setMobileViewActive(true);
|
|
3175
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
3176
|
+
const savedSessionId = this._getSavedSessionId();
|
|
3177
|
+
if (savedSessionId) {
|
|
3178
|
+
this.connect({ mode: "attach", quiet: true });
|
|
3179
|
+
} else {
|
|
3180
|
+
this.connect({ mode: "new", quiet: true });
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
});
|
|
3185
|
+
|
|
3186
|
+
this.textInputTextareaEl.addEventListener("blur", () => {
|
|
3187
|
+
// Wait a tick so activeElement updates.
|
|
3188
|
+
setTimeout(() => {
|
|
3189
|
+
if (document.activeElement !== this.textInputTextareaEl) {
|
|
3190
|
+
this.textInputWasFocused = false;
|
|
3191
|
+
}
|
|
3192
|
+
this._updateComposerSticky();
|
|
3193
|
+
this._captureTerminalScrollState();
|
|
3194
|
+
this.deferScrollRestore = true;
|
|
3195
|
+
if (this.isTouchDevice() && isMobileViewport()) {
|
|
3196
|
+
// Exit the scroll-only view so taps go directly to the TUI again.
|
|
3197
|
+
this._scheduleResizeAfterLayout();
|
|
3198
|
+
this._setMobileViewActive(false);
|
|
3199
|
+
}
|
|
3200
|
+
}, 0);
|
|
3201
|
+
});
|
|
3202
|
+
|
|
3203
|
+
if (this.textInputImageBtn && this.textInputImageInputEl) {
|
|
3204
|
+
this.terminalSectionEl.addEventListener("paste", (e) => {
|
|
3205
|
+
if (document.activeElement === this.textInputTextareaEl) return;
|
|
3206
|
+
const items = e.clipboardData?.items;
|
|
3207
|
+
if (!items || !items.length) return;
|
|
3208
|
+
const files = [];
|
|
3209
|
+
for (const item of items) {
|
|
3210
|
+
if (item.type && item.type.startsWith("image/")) {
|
|
3211
|
+
const file = item.getAsFile();
|
|
3212
|
+
if (file) files.push(file);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
if (!files.length) return;
|
|
3216
|
+
e.preventDefault();
|
|
3217
|
+
this._handleImageFiles(files);
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
this.textInputPending = this._loadPendingTextInput();
|
|
3222
|
+
this._restoreTextInputDraft();
|
|
3223
|
+
if (this.textInputPending && this.textInputTextareaEl && !this.textInputTextareaEl.value) {
|
|
3224
|
+
this.textInputTextareaEl.value = this.textInputPending.originalText || "";
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
this._setTextInputEnabled(this.textInputEnabled, { focus: false });
|
|
3228
|
+
this._updateViewportInsets();
|
|
3229
|
+
this._updateComposerSticky();
|
|
3230
|
+
this._updateTextInputConnected(
|
|
3231
|
+
Boolean(this.socket && this.socket.readyState === WebSocket.OPEN)
|
|
3232
|
+
);
|
|
3233
|
+
|
|
3234
|
+
if (this.textInputPending) {
|
|
3235
|
+
const savedSessionId = this._getSavedSessionId();
|
|
3236
|
+
if (savedSessionId && (!this.socket || this.socket.readyState !== WebSocket.OPEN)) {
|
|
3237
|
+
this.connect({ mode: "attach", quiet: true });
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
// ==================== MOBILE CONTROLS ====================
|
|
3243
|
+
|
|
3244
|
+
_sendKey(seq) {
|
|
3245
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
|
3246
|
+
|
|
3247
|
+
// If ctrl modifier is active, convert to ctrl code
|
|
3248
|
+
if (this.ctrlActive && seq.length === 1) {
|
|
3249
|
+
const char = seq.toUpperCase();
|
|
3250
|
+
const code = char.charCodeAt(0) - 64;
|
|
3251
|
+
if (code >= 1 && code <= 26) {
|
|
3252
|
+
seq = String.fromCharCode(code);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
this._markSessionActive();
|
|
3257
|
+
this.socket.send(textEncoder.encode(seq));
|
|
3258
|
+
|
|
3259
|
+
// Reset modifiers after sending
|
|
3260
|
+
this.ctrlActive = false;
|
|
3261
|
+
this.altActive = false;
|
|
3262
|
+
this._updateModifierButtons();
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
_sendCtrl(char) {
|
|
3266
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
|
|
3267
|
+
const code = char.toUpperCase().charCodeAt(0) - 64;
|
|
3268
|
+
this._markSessionActive();
|
|
3269
|
+
this.socket.send(textEncoder.encode(String.fromCharCode(code)));
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
_updateModifierButtons() {
|
|
3273
|
+
const ctrlBtn = document.getElementById("tmb-ctrl");
|
|
3274
|
+
const altBtn = document.getElementById("tmb-alt");
|
|
3275
|
+
if (ctrlBtn) ctrlBtn.classList.toggle("active", this.ctrlActive);
|
|
3276
|
+
if (altBtn) altBtn.classList.toggle("active", this.altActive);
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
_initMobileControls() {
|
|
3280
|
+
this.mobileControlsEl = document.getElementById("terminal-mobile-controls");
|
|
3281
|
+
|
|
3282
|
+
if (!this.mobileControlsEl) return;
|
|
3283
|
+
|
|
3284
|
+
// Only show on touch devices
|
|
3285
|
+
if (!this.isTouchDevice()) {
|
|
3286
|
+
this.mobileControlsEl.style.display = "none";
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
// Handle all key buttons
|
|
3291
|
+
this.mobileControlsEl.addEventListener("click", (e) => {
|
|
3292
|
+
const btn = e.target.closest(".tmb-key");
|
|
3293
|
+
if (!btn) return;
|
|
3294
|
+
|
|
3295
|
+
e.preventDefault();
|
|
3296
|
+
|
|
3297
|
+
// Handle modifier toggles
|
|
3298
|
+
const modKey = btn.dataset.key;
|
|
3299
|
+
if (modKey === "ctrl") {
|
|
3300
|
+
this.ctrlActive = !this.ctrlActive;
|
|
3301
|
+
this._updateModifierButtons();
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
if (modKey === "alt") {
|
|
3305
|
+
this.altActive = !this.altActive;
|
|
3306
|
+
this._updateModifierButtons();
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
// Handle Ctrl+X combos
|
|
3311
|
+
const ctrlChar = btn.dataset.ctrl;
|
|
3312
|
+
if (ctrlChar) {
|
|
3313
|
+
this._sendCtrl(ctrlChar);
|
|
3314
|
+
if (this.isTouchDevice() && this.textInputEnabled && this.textInputWasFocused) {
|
|
3315
|
+
setTimeout(() => this._safeFocus(this.textInputTextareaEl), 0);
|
|
3316
|
+
}
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
// Handle direct sequences (arrows, esc, tab)
|
|
3321
|
+
const seq = btn.dataset.seq;
|
|
3322
|
+
if (seq) {
|
|
3323
|
+
this._sendKey(seq);
|
|
3324
|
+
if (this.isTouchDevice() && this.textInputEnabled && this.textInputWasFocused) {
|
|
3325
|
+
setTimeout(() => this._safeFocus(this.textInputTextareaEl), 0);
|
|
3326
|
+
}
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
});
|
|
3330
|
+
|
|
3331
|
+
// Add haptic feedback on touch if available
|
|
3332
|
+
this.mobileControlsEl.addEventListener(
|
|
3333
|
+
"touchstart",
|
|
3334
|
+
(e) => {
|
|
3335
|
+
if (e.target.closest(".tmb-key") && navigator.vibrate) {
|
|
3336
|
+
navigator.vibrate(10);
|
|
3337
|
+
}
|
|
3338
|
+
},
|
|
3339
|
+
{ passive: true }
|
|
3340
|
+
);
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
// ==================== VOICE INPUT ====================
|
|
3344
|
+
|
|
3345
|
+
_insertTranscriptIntoTextInput(text) {
|
|
3346
|
+
if (!text) return false;
|
|
3347
|
+
if (!this.textInputTextareaEl) return false;
|
|
3348
|
+
|
|
3349
|
+
if (!this.textInputEnabled) {
|
|
3350
|
+
this._setTextInputEnabled(true, { focus: true, focusTextarea: true });
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
const transcript = String(text).trim();
|
|
3354
|
+
if (!transcript) return false;
|
|
3355
|
+
|
|
3356
|
+
const existing = this.textInputTextareaEl.value || "";
|
|
3357
|
+
let next = existing;
|
|
3358
|
+
if (existing && !/\s$/.test(existing)) {
|
|
3359
|
+
next += " ";
|
|
3360
|
+
}
|
|
3361
|
+
next += transcript;
|
|
3362
|
+
next = this._appendVoiceTranscriptDisclaimer(next);
|
|
3363
|
+
this.textInputTextareaEl.value = next;
|
|
3364
|
+
this._persistTextInputDraft();
|
|
3365
|
+
this._updateComposerSticky();
|
|
3366
|
+
this._safeFocus(this.textInputTextareaEl);
|
|
3367
|
+
return true;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
_appendVoiceTranscriptDisclaimer(text) {
|
|
3371
|
+
const base = text === undefined || text === null ? "" : String(text);
|
|
3372
|
+
if (!base.trim()) return base;
|
|
3373
|
+
const injection = wrapInjectedContextIfNeeded(VOICE_TRANSCRIPT_DISCLAIMER_TEXT);
|
|
3374
|
+
if (
|
|
3375
|
+
base.includes(VOICE_TRANSCRIPT_DISCLAIMER_TEXT) ||
|
|
3376
|
+
base.includes(injection)
|
|
3377
|
+
) {
|
|
3378
|
+
return base;
|
|
3379
|
+
}
|
|
3380
|
+
const separator = base.endsWith("\n") ? "\n" : "\n\n";
|
|
3381
|
+
return `${base}${separator}${injection}`;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
_sendVoiceTranscript(text) {
|
|
3385
|
+
if (!text) {
|
|
3386
|
+
flash("Voice capture returned no transcript", "error");
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
if (this.isTouchDevice() || this.textInputEnabled) {
|
|
3390
|
+
if (this._insertTranscriptIntoTextInput(text)) {
|
|
3391
|
+
flash("Voice transcript added to text input");
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
3396
|
+
flash("Connect the terminal before using voice input", "error");
|
|
3397
|
+
if (this.voiceStatus) {
|
|
3398
|
+
this.voiceStatus.textContent = "Connect to send voice";
|
|
3399
|
+
this.voiceStatus.classList.remove("hidden");
|
|
3400
|
+
}
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
const message = this._appendVoiceTranscriptDisclaimer(text);
|
|
3404
|
+
const payload = message.endsWith("\n") ? message : `${message}\n`;
|
|
3405
|
+
this.socket.send(textEncoder.encode(payload));
|
|
3406
|
+
this.term?.focus();
|
|
3407
|
+
flash("Voice transcript sent to terminal");
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
_matchesVoiceHotkey(event) {
|
|
3411
|
+
return event.key && event.key.toLowerCase() === "v" && event.altKey;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
_handleVoiceHotkeyDown(event) {
|
|
3415
|
+
if (!this.voiceController || this.voiceKeyActive) return;
|
|
3416
|
+
if (!this._matchesVoiceHotkey(event)) return;
|
|
3417
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
3418
|
+
flash("Connect the terminal before using voice input", "error");
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
event.preventDefault();
|
|
3422
|
+
event.stopPropagation();
|
|
3423
|
+
this.voiceKeyActive = true;
|
|
3424
|
+
this.voiceController.start();
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
_handleVoiceHotkeyUp(event) {
|
|
3428
|
+
if (!this.voiceKeyActive) return;
|
|
3429
|
+
if (event && this._matchesVoiceHotkey(event)) {
|
|
3430
|
+
event.preventDefault();
|
|
3431
|
+
event.stopPropagation();
|
|
3432
|
+
}
|
|
3433
|
+
this.voiceKeyActive = false;
|
|
3434
|
+
this.voiceController?.stop();
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
_initTerminalVoice() {
|
|
3438
|
+
this.voiceBtn = document.getElementById("terminal-voice");
|
|
3439
|
+
this.voiceStatus = document.getElementById("terminal-voice-status");
|
|
3440
|
+
this.mobileVoiceBtn = document.getElementById("terminal-mobile-voice");
|
|
3441
|
+
this.textVoiceBtn = document.getElementById("terminal-text-voice");
|
|
3442
|
+
|
|
3443
|
+
// Initialize desktop toolbar voice button
|
|
3444
|
+
if (this.voiceBtn && this.voiceStatus) {
|
|
3445
|
+
initVoiceInput({
|
|
3446
|
+
button: this.voiceBtn,
|
|
3447
|
+
input: null,
|
|
3448
|
+
statusEl: this.voiceStatus,
|
|
3449
|
+
onTranscript: (text) => this._sendVoiceTranscript(text),
|
|
3450
|
+
onError: (msg) => {
|
|
3451
|
+
if (!msg) return;
|
|
3452
|
+
flash(msg, "error");
|
|
3453
|
+
this.voiceStatus.textContent = msg;
|
|
3454
|
+
this.voiceStatus.classList.remove("hidden");
|
|
3455
|
+
},
|
|
3456
|
+
})
|
|
3457
|
+
.then((controller) => {
|
|
3458
|
+
if (!controller) {
|
|
3459
|
+
this.voiceBtn.closest(".terminal-voice")?.classList.add("hidden");
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
this.voiceController = controller;
|
|
3463
|
+
if (this.voiceStatus) {
|
|
3464
|
+
const base = this.voiceStatus.textContent || "Hold to talk";
|
|
3465
|
+
this.voiceStatus.textContent = `${base} (Alt+V)`;
|
|
3466
|
+
this.voiceStatus.classList.remove("hidden");
|
|
3467
|
+
}
|
|
3468
|
+
window.addEventListener("keydown", this._handleVoiceHotkeyDown);
|
|
3469
|
+
window.addEventListener("keyup", this._handleVoiceHotkeyUp);
|
|
3470
|
+
window.addEventListener("blur", () => {
|
|
3471
|
+
if (this.voiceKeyActive) {
|
|
3472
|
+
this.voiceKeyActive = false;
|
|
3473
|
+
this.voiceController?.stop();
|
|
3474
|
+
}
|
|
3475
|
+
});
|
|
3476
|
+
})
|
|
3477
|
+
.catch((err) => {
|
|
3478
|
+
console.error("Voice init failed", err);
|
|
3479
|
+
flash("Voice capture unavailable", "error");
|
|
3480
|
+
this.voiceStatus.textContent = "Voice unavailable";
|
|
3481
|
+
this.voiceStatus.classList.remove("hidden");
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
// Initialize mobile voice button
|
|
3486
|
+
if (this.mobileVoiceBtn) {
|
|
3487
|
+
initVoiceInput({
|
|
3488
|
+
button: this.mobileVoiceBtn,
|
|
3489
|
+
input: null,
|
|
3490
|
+
statusEl: null,
|
|
3491
|
+
onTranscript: (text) => this._sendVoiceTranscript(text),
|
|
3492
|
+
onError: (msg) => {
|
|
3493
|
+
if (!msg) return;
|
|
3494
|
+
flash(msg, "error");
|
|
3495
|
+
},
|
|
3496
|
+
})
|
|
3497
|
+
.then((controller) => {
|
|
3498
|
+
if (!controller) {
|
|
3499
|
+
this.mobileVoiceBtn.classList.add("hidden");
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
this.mobileVoiceController = controller;
|
|
3503
|
+
})
|
|
3504
|
+
.catch((err) => {
|
|
3505
|
+
console.error("Mobile voice init failed", err);
|
|
3506
|
+
this.mobileVoiceBtn.classList.add("hidden");
|
|
3507
|
+
});
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
// Initialize text-input voice button (compact waveform mode)
|
|
3511
|
+
if (this.textVoiceBtn) {
|
|
3512
|
+
initVoiceInput({
|
|
3513
|
+
button: this.textVoiceBtn,
|
|
3514
|
+
input: null,
|
|
3515
|
+
statusEl: null,
|
|
3516
|
+
onTranscript: (text) => this._sendVoiceTranscript(text),
|
|
3517
|
+
onError: (msg) => {
|
|
3518
|
+
if (!msg) return;
|
|
3519
|
+
flash(msg, "error");
|
|
3520
|
+
},
|
|
3521
|
+
})
|
|
3522
|
+
.then((controller) => {
|
|
3523
|
+
if (!controller) {
|
|
3524
|
+
this.textVoiceBtn.classList.add("hidden");
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
this.textVoiceController = controller;
|
|
3528
|
+
})
|
|
3529
|
+
.catch((err) => {
|
|
3530
|
+
console.error("Text voice init failed", err);
|
|
3531
|
+
this.textVoiceBtn.classList.add("hidden");
|
|
3532
|
+
});
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
}
|