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.
Files changed (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. 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, "&amp;")
1230
+ .replace(/</g, "&lt;")
1231
+ .replace(/>/g, "&gt;")
1232
+ .replace(/"/g, "&quot;")
1233
+ .replace(/'/g, "&#39;");
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
+ }