fpasoterm 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fpasoterm might be problematic. Click here for more details.

Files changed (101) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/CONTRIBUTING.md +67 -0
  3. package/INSTALL.ja.md +204 -0
  4. package/INSTALL.md +229 -0
  5. package/LICENSE +21 -0
  6. package/README.ja.md +389 -0
  7. package/README.md +607 -0
  8. package/bin/fpasoterm +2882 -0
  9. package/completions/_fpasoterm +81 -0
  10. package/completions/fpasoterm.bash +55 -0
  11. package/completions/fpasoterm.fish +64 -0
  12. package/completions/fpasoterm.ps1 +47 -0
  13. package/docs/capability-diagnostics.en.md +77 -0
  14. package/docs/capability-diagnostics.ja.md +63 -0
  15. package/docs/completion.en.md +141 -0
  16. package/docs/completion.ja.md +113 -0
  17. package/docs/config.en.md +554 -0
  18. package/docs/config.ja.md +544 -0
  19. package/docs/debugging.en.md +72 -0
  20. package/docs/debugging.ja.md +66 -0
  21. package/docs/diagnostics.en.md +69 -0
  22. package/docs/diagnostics.ja.md +64 -0
  23. package/docs/font-diagnostics.en.md +116 -0
  24. package/docs/font-diagnostics.ja.md +105 -0
  25. package/docs/fpasoterm-plugin.d.ts +51 -0
  26. package/docs/known-issues.en.md +23 -0
  27. package/docs/known-issues.ja.md +23 -0
  28. package/docs/plugins.en.md +266 -0
  29. package/docs/plugins.ja.md +176 -0
  30. package/docs/pr-review.en.md +177 -0
  31. package/docs/pr-review.ja.md +170 -0
  32. package/docs/release-checklist.en.md +72 -0
  33. package/docs/release-checklist.ja.md +71 -0
  34. package/docs/security.en.md +36 -0
  35. package/docs/security.ja.md +31 -0
  36. package/docs/spec.en.md +106 -0
  37. package/docs/spec.ja.md +99 -0
  38. package/docs/sshfs.en.md +28 -0
  39. package/docs/sshfs.ja.md +28 -0
  40. package/docs/sync.en.md +280 -0
  41. package/docs/sync.ja.md +280 -0
  42. package/examples/apply-default-appearance.bat +8 -0
  43. package/examples/apply-default-appearance.ps1 +7 -0
  44. package/examples/apply-default-appearance.sh +7 -0
  45. package/examples/apply-runtime-appearance.bat +8 -0
  46. package/examples/apply-runtime-appearance.ps1 +7 -0
  47. package/examples/apply-runtime-appearance.sh +7 -0
  48. package/examples/config/default-appearance.toml +36 -0
  49. package/examples/config/minimal.toml +30 -0
  50. package/examples/config/profiles.toml +20 -0
  51. package/examples/config/runtime-appearance.toml +24 -0
  52. package/examples/config/sync-folder.toml +17 -0
  53. package/examples/config/tui-compatibility.toml +7 -0
  54. package/examples/config/with-plugins.toml +33 -0
  55. package/examples/plugins/hello.ts +17 -0
  56. package/examples/plugins/status-banner.ts +14 -0
  57. package/examples/plugins/theme.ts +32 -0
  58. package/examples/plugins/welcome-banner.ts +16 -0
  59. package/extra/linux/icons/hicolor/128x128/apps/fpasoterm.png +0 -0
  60. package/extra/linux/icons/hicolor/16x16/apps/fpasoterm.png +0 -0
  61. package/extra/linux/icons/hicolor/192x192/apps/fpasoterm.png +0 -0
  62. package/extra/linux/icons/hicolor/256x256/apps/fpasoterm.png +0 -0
  63. package/extra/linux/icons/hicolor/32x32/apps/fpasoterm.png +0 -0
  64. package/extra/linux/icons/hicolor/48x48/apps/fpasoterm.png +0 -0
  65. package/extra/linux/icons/hicolor/512x512/apps/fpasoterm.png +0 -0
  66. package/extra/linux/icons/hicolor/64x64/apps/fpasoterm.png +0 -0
  67. package/extra/linux/io.github.oyoguhito.fpasoterm.desktop +10 -0
  68. package/extra/logo/fpasoterm.png +0 -0
  69. package/extra/macos/fpasoterm.icns +0 -0
  70. package/extra/windows/fpasoterm.cmd +40 -0
  71. package/extra/windows/fpasoterm.ico +0 -0
  72. package/package.json +81 -0
  73. package/scripts/build-artifacts.js +183 -0
  74. package/scripts/generate-icon.js +251 -0
  75. package/scripts/init-jj-empty-main +41 -0
  76. package/scripts/install-linux-desktop.js +238 -0
  77. package/scripts/run +16 -0
  78. package/scripts/security/scan-secrets.js +89 -0
  79. package/scripts/tests/smoke.js +2476 -0
  80. package/scripts/uninstall-desktop.js +20 -0
  81. package/scripts/uninstall-linux-desktop.js +96 -0
  82. package/scripts/uninstall-windows-path.js +94 -0
  83. package/src/config.js +855 -0
  84. package/src/renderer/confirm.html +71 -0
  85. package/src/renderer/index.html +223 -0
  86. package/src/renderer/renderer.js +4166 -0
  87. package/src/renderer/styles.css +969 -0
  88. package/src/renderer/vendor/addon-fit/addon-fit.js +2 -0
  89. package/src/renderer/vendor/addon-image/LICENSE +19 -0
  90. package/src/renderer/vendor/addon-image/addon-image.js +3 -0
  91. package/src/renderer/vendor/addon-web-links/LICENSE +19 -0
  92. package/src/renderer/vendor/addon-web-links/addon-web-links.js +2 -0
  93. package/src/renderer/vendor/xterm/xterm.css +292 -0
  94. package/src/renderer/vendor/xterm/xterm.js +2 -0
  95. package/src-tauri/Cargo.lock +5253 -0
  96. package/src-tauri/Cargo.toml +29 -0
  97. package/src-tauri/build.rs +140 -0
  98. package/src-tauri/capabilities/default.json +13 -0
  99. package/src-tauri/default-config.toml +128 -0
  100. package/src-tauri/src/main.rs +9347 -0
  101. package/src-tauri/tauri.conf.json +60 -0
@@ -0,0 +1,4166 @@
1
+ const terminalElement = document.getElementById('terminal');
2
+ const imePreeditElement = document.getElementById('ime-preedit');
3
+ const diagnosticsPanel = document.getElementById('diagnostics-panel');
4
+ const diagnosticsTitleElement = document.getElementById('diagnostics-title');
5
+ const diagnosticsElement = document.getElementById('diagnostics');
6
+ const fontGlyphPreviewElement = document.getElementById('font-glyph-preview');
7
+ const terminalCapabilityPreviewElement = document.getElementById('terminal-capability-preview');
8
+ const diagnosticsPathElement = document.getElementById('diagnostics-path');
9
+ const closeDiagnosticsButton = document.getElementById('close-diagnostics');
10
+ const terminalLogSelectElement = document.getElementById('terminal-log-select');
11
+ const terminalLogSearchElement = document.getElementById('terminal-log-search');
12
+ const terminalLogSearchNextButton = document.getElementById('terminal-log-search-next');
13
+ const terminalLogSearchStatusElement = document.getElementById('terminal-log-search-status');
14
+ const terminalLogShowSelectedButton = document.getElementById('terminal-log-show-selected');
15
+ const terminalLogDeleteSelectedButton = document.getElementById('terminal-log-delete-selected');
16
+ const terminalLogDeleteAllButton = document.getElementById('terminal-log-delete-all');
17
+ const checkForUpdatesButton = document.getElementById('check-for-updates');
18
+ const terminalEncodingSelectElement = document.getElementById('terminal-encoding-select');
19
+ const terminalEncodingApplyButton = document.getElementById('terminal-encoding-apply');
20
+ const terminalLogConfirmElement = document.getElementById('terminal-log-confirm');
21
+ const terminalLogConfirmMessageElement = document.getElementById('terminal-log-confirm-message');
22
+ const terminalLogConfirmOkButton = document.getElementById('terminal-log-confirm-ok');
23
+ const terminalLogConfirmCancelButton = document.getElementById('terminal-log-confirm-cancel');
24
+ const closeWindowButton = document.getElementById('close-window');
25
+ const minimizeWindowButton = document.getElementById('minimize-window');
26
+ const maximizeWindowButton = document.getElementById('maximize-window');
27
+ const newWindowButton = document.getElementById('new-window');
28
+ const newWindowCwdButton = document.getElementById('new-window-cwd');
29
+ const sshfsManagerButton = document.getElementById('sshfs-manager');
30
+ const arrangeWindowButton = document.getElementById('arrange-window');
31
+ const closeAllWindowsButton = document.getElementById('close-all-windows');
32
+ const keyboardShortcutsHelpButton = document.getElementById('keyboard-shortcuts-help');
33
+ const closeAllConfirmElement = document.getElementById('close-all-confirm');
34
+ const closeAllConfirmMessageElement = document.getElementById('close-all-confirm-message');
35
+ const closeAllConfirmOkButton = document.getElementById('close-all-confirm-ok');
36
+ const closeAllConfirmCancelButton = document.getElementById('close-all-confirm-cancel');
37
+ const windowMenu = document.getElementById('window-menu');
38
+ const windowMenuToggleButton = document.getElementById('window-menu-toggle');
39
+ const windowMenuItems = document.getElementById('window-menu-items');
40
+ const keybindingPrefixElement = document.getElementById('keybinding-prefix');
41
+ const terminalLogStatusElement = document.getElementById('terminal-log-status');
42
+ const sshfsMountStatusElement = document.getElementById('sshfs-mount-status');
43
+ const imeStatusElement = document.getElementById('ime-status');
44
+ const logMenuToggleButton = document.getElementById('log-menu-toggle');
45
+ const logMenuItems = document.getElementById('log-menu-items');
46
+ const terminalLogToggleButton = document.getElementById('terminal-log-toggle');
47
+ const terminalLogShowButton = document.getElementById('terminal-log-show');
48
+ const syncMenuToggleButton = document.getElementById('sync-menu-toggle');
49
+ const syncMenuItems = document.getElementById('sync-menu-items');
50
+ const syncStatusButton = document.getElementById('sync-status');
51
+ const syncCleanButton = document.getElementById('sync-clean');
52
+ const diagnosticsMenuToggleButton = document.getElementById('diagnostics-menu-toggle');
53
+ const diagnosticsMenuItems = document.getElementById('diagnostics-menu-items');
54
+ const fontGlyphTestButton = document.getElementById('font-glyph-test');
55
+ const terminalCapabilityTestButton = document.getElementById('terminal-capability-test');
56
+ const windowActionsMenuToggleButton = document.getElementById('window-actions-menu-toggle');
57
+ const windowActionsMenuItems = document.getElementById('window-actions-menu-items');
58
+ const pluginMenuSection = document.getElementById('plugin-menu-section');
59
+ const pluginMenuToggleButton = document.getElementById('plugin-menu-toggle');
60
+ const terminalKillButton = document.getElementById('terminal-kill');
61
+ const terminalCopyButton = document.getElementById('terminal-copy');
62
+ const terminalPasteButton = document.getElementById('terminal-paste');
63
+ const pluginCommandItems = document.getElementById('plugin-command-items');
64
+ const terminalBroadcastButton = document.getElementById('terminal-broadcast');
65
+ const terminalBroadcastDialog = document.getElementById('terminal-broadcast-dialog');
66
+ const terminalBroadcastTitle = document.getElementById('terminal-broadcast-title');
67
+ const terminalBroadcastText = document.getElementById('terminal-broadcast-text');
68
+ const terminalBroadcastControl = document.getElementById('terminal-broadcast-control');
69
+ const terminalBroadcastControlInsertButton = document.getElementById('terminal-broadcast-control-insert');
70
+ const terminalBroadcastControlStatus = document.getElementById('terminal-broadcast-control-status');
71
+ const terminalBroadcastFocusStatus = document.getElementById('terminal-broadcast-focus-status');
72
+ const terminalBroadcastTargetList = document.getElementById('terminal-broadcast-target-list');
73
+ const terminalBroadcastSelectAllButton = document.getElementById('terminal-broadcast-select-all');
74
+ const terminalBroadcastSelectNoneButton = document.getElementById('terminal-broadcast-select-none');
75
+ const terminalBroadcastSync = document.getElementById('terminal-broadcast-sync');
76
+ const terminalBroadcastSyncLabel = document.getElementById('terminal-broadcast-sync-label');
77
+ const terminalBroadcastSendButton = document.getElementById('terminal-broadcast-send');
78
+ const terminalBroadcastCancelButton = document.getElementById('terminal-broadcast-cancel');
79
+ const terminalBroadcastConfirmElement = document.getElementById('terminal-broadcast-confirm');
80
+ const terminalBroadcastConfirmMessageElement = document.getElementById('terminal-broadcast-confirm-message');
81
+ const terminalBroadcastConfirmOkButton = document.getElementById('terminal-broadcast-confirm-ok');
82
+ const terminalBroadcastConfirmCancelButton = document.getElementById('terminal-broadcast-confirm-cancel');
83
+ const terminalUrlDialog = document.getElementById('terminal-url-dialog');
84
+ const terminalUrlMessageElement = document.getElementById('terminal-url-message');
85
+ const terminalUrlCopyButton = document.getElementById('terminal-url-copy');
86
+ const terminalUrlOpenButton = document.getElementById('terminal-url-open');
87
+ const terminalUrlCancelButton = document.getElementById('terminal-url-cancel');
88
+ const sshfsManagerDialog = document.getElementById('sshfs-manager-dialog');
89
+ const sshfsManagerResult = document.getElementById('sshfs-manager-result');
90
+ const sshfsManagerSavedMounts = document.getElementById('sshfs-manager-saved-mounts');
91
+ const sshfsManagerForm = document.getElementById('sshfs-manager-form');
92
+ const sshfsManagerCloseButton = document.getElementById('sshfs-manager-close');
93
+ const sshfsManagerUnmountButton = document.getElementById('sshfs-manager-unmount');
94
+ const sshfsManagerUnmountAllButton = document.getElementById('sshfs-manager-unmount-all');
95
+ const sshfsManagerForgetButton = document.getElementById('sshfs-manager-forget');
96
+ const sshfsManagerPasswordElement = document.getElementById('sshfs-manager-password');
97
+ const sshfsManagerPasswordToggleButton = document.getElementById('sshfs-manager-password-toggle');
98
+ const sshfsManagerLocalPathElement = document.getElementById('sshfs-manager-local-path');
99
+ const windowTitleElement = document.getElementById('window-title');
100
+ const terminalMirrorElement = document.getElementById('terminal-mirror');
101
+ let debugKeys = new URLSearchParams(window.location.search).has('debugKeys');
102
+ const diagnosticLines = [];
103
+ let terminalMirrorText = '';
104
+ let closeAllConfirmResolver = null;
105
+ let terminalBroadcastConfirmResolver = null;
106
+ const fallbackConfig = {
107
+ window: {
108
+ backgroundColor: 'rgba(0, 0, 0, 0)',
109
+ title: 'fpasoterm',
110
+ titlebarColor: '#1565c0',
111
+ titleLocked: true,
112
+ },
113
+ terminal: {
114
+ allowTransparency: true,
115
+ cursorBlink: true,
116
+ cursorStyle: 'block',
117
+ fontFamily: '"DejaVu Sans Mono", "Noto Sans Mono", "Noto Sans Mono CJK JP", "Noto Sans Mono CJK KR", "Noto Sans Mono CJK SC", "NanumGothicCoding", "BIZ UDGothic", "Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", "Hiragino Kaku Gothic ProN", "Apple SD Gothic Neo", "Malgun Gothic", Meiryo, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
118
+ fontSize: 14,
119
+ lineHeight: 1,
120
+ minimumContrastRatio: 1,
121
+ rescaleOverlappingGlyphs: false,
122
+ backgroundOpacity: 0.65,
123
+ scrollback: 1000,
124
+ termName: 'xterm-256color',
125
+ encoding: 'utf-8',
126
+ theme: {
127
+ background: 'rgba(16, 19, 23, 0.65)',
128
+ foreground: '#e8edf2',
129
+ cursor: '#f5d76e',
130
+ selectionBackground: '#35506b',
131
+ black: '#11151a',
132
+ red: '#ff6b6b',
133
+ green: '#8bd17c',
134
+ yellow: '#f5d76e',
135
+ blue: '#7bb7ff',
136
+ magenta: '#d7a8ff',
137
+ cyan: '#63d4d5',
138
+ white: '#e8edf2',
139
+ brightBlack: '#5d6978',
140
+ brightRed: '#ff8f8f',
141
+ brightGreen: '#ade89f',
142
+ brightYellow: '#ffe08a',
143
+ brightBlue: '#a4ceff',
144
+ brightMagenta: '#e3c3ff',
145
+ brightCyan: '#9de9ea',
146
+ brightWhite: '#ffffff',
147
+ },
148
+ },
149
+ keybindings: {
150
+ prefix: 'Mod+Shift',
151
+ logMenu: 'L',
152
+ logToggle: 'S',
153
+ logShow: 'P',
154
+ copy: 'C',
155
+ paste: 'V',
156
+ menu: 'M',
157
+ help: 'H',
158
+ newWindow: 'N',
159
+ openCwd: 'O',
160
+ broadcast: 'B',
161
+ kill: 'K',
162
+ tile: 'T',
163
+ closeAll: 'X',
164
+ },
165
+ plugins: {
166
+ enabled: [],
167
+ },
168
+ sync: {
169
+ enabled: false,
170
+ provider: 'folder',
171
+ path: '',
172
+ channel: 'default',
173
+ diagnostics: true,
174
+ maxBytes: 1048576,
175
+ },
176
+ logging: {
177
+ enabled: true,
178
+ directory: '',
179
+ autoStart: false,
180
+ maxBytes: 10485760,
181
+ },
182
+ security: {
183
+ osc52: 'trusted',
184
+ osc52MaxBytes: 65536,
185
+ osc7: true,
186
+ osc133: true,
187
+ osc8Open: false,
188
+ oscNotifications: false,
189
+ oscNotificationMinIntervalMs: 5000,
190
+ },
191
+ };
192
+ let appConfig = fallbackConfig;
193
+ let activeConfigPath = '';
194
+ let pluginUrls = [];
195
+ let pluginVersion = 'unknown';
196
+ let pluginsReady = false;
197
+ let pluginReadyTimer = null;
198
+ let pluginReadyGeneration = 0;
199
+ const pluginReadyCallbacks = [];
200
+ const pluginCommands = new Map();
201
+ let term;
202
+ let fitAddon;
203
+ let imageAddon;
204
+ let terminalBroadcastTargets = [];
205
+ let imePreeditClearTimer = null;
206
+ let imeTraceUntil = 0;
207
+ let windowStateSaveTimer = null;
208
+ let terminalResizeTimer = null;
209
+ let terminalDeferredResizeTimer = null;
210
+ let xtermOverlayObserver = null;
211
+ let pendingOscData = '';
212
+ let pendingTerminalUrl = '';
213
+ let lastOscNotificationAt = 0;
214
+ // OSC 7/133 are metadata only. They never run shell commands or access paths.
215
+ const oscSessionMetadata = {
216
+ cwd: '',
217
+ cwdHost: '',
218
+ commandState: 'unknown',
219
+ lastExitCode: '',
220
+ lastMarker: '',
221
+ lastNotification: '',
222
+ };
223
+ let diagnosticsPanelMode = 'diagnostics';
224
+ let syncDiagnosticsEnabled = false;
225
+ let syncDiagnosticsTimer = null;
226
+ let terminalLogSearchState = {
227
+ query: '',
228
+ text: '',
229
+ matches: [],
230
+ cursor: -1,
231
+ };
232
+ let terminalLogConfirmResolver = null;
233
+ let terminalLogConfirmReturnFocus = null;
234
+
235
+ // Resolves a configurable shortcut. A single key inherits keybindings.prefix;
236
+ // full values such as Ctrl+Alt+KeyN override the prefix for that action.
237
+ function keybindingSpec(name) {
238
+ const defaults = fallbackConfig.keybindings;
239
+ const settings = appConfig.keybindings || {};
240
+ const configured = String(settings[name] ?? defaults[name] ?? '').trim() || defaults[name];
241
+ const prefix = configuredKeybindingPrefix();
242
+ if (configured.includes('+')) {
243
+ return isValidFullKeybinding(configured) ? configured : `${prefix}+${defaults[name]}`;
244
+ }
245
+ return `${prefix}+${configured}`;
246
+ }
247
+
248
+ // Only modifier names may be used in the shared prefix. Key names such as
249
+ // Escape belong to an action binding, for example "Ctrl+Alt+Escape".
250
+ function isKeybindingModifier(token) {
251
+ return ['ctrl', 'control', 'alt', 'option', 'shift', 'meta', 'cmd', 'command', 'mod']
252
+ .includes(token.toLowerCase());
253
+ }
254
+
255
+ function keybindingTokens(value) {
256
+ return String(value).split('+').map((token) => token.trim()).filter(Boolean);
257
+ }
258
+
259
+ function isValidKeybindingPrefix(value) {
260
+ const tokens = keybindingTokens(value);
261
+ return tokens.length > 0 && tokens.every(isKeybindingModifier);
262
+ }
263
+
264
+ function isValidFullKeybinding(value) {
265
+ const tokens = keybindingTokens(value);
266
+ return tokens.length > 1
267
+ && tokens.slice(0, -1).every(isKeybindingModifier)
268
+ && !isKeybindingModifier(tokens[tokens.length - 1]);
269
+ }
270
+
271
+ function configuredKeybindingPrefix() {
272
+ const configured = String(appConfig.keybindings?.prefix ?? '').trim();
273
+ return isValidKeybindingPrefix(configured) ? configured : fallbackConfig.keybindings.prefix;
274
+ }
275
+
276
+ // Matches the configured shortcut by key value or a physical KeyN-style code.
277
+ function matchesKeybinding(event, name) {
278
+ const tokens = keybindingSpec(name).split('+').map((token) => token.trim()).filter(Boolean);
279
+ const key = tokens.pop();
280
+ if (!key) {
281
+ return false;
282
+ }
283
+ const modifiers = new Set(tokens.map((token) => token.toLowerCase()));
284
+ const isMac = navigator.platform.toLowerCase().includes('mac');
285
+ const wantsCtrl = modifiers.has('ctrl') || modifiers.has('control') || (modifiers.has('mod') && !isMac);
286
+ const wantsMeta = modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command') || (modifiers.has('mod') && isMac);
287
+ const wantsAlt = modifiers.has('alt') || modifiers.has('option');
288
+ const wantsShift = modifiers.has('shift');
289
+ if (event.ctrlKey !== wantsCtrl || event.metaKey !== wantsMeta || event.altKey !== wantsAlt || event.shiftKey !== wantsShift) {
290
+ return false;
291
+ }
292
+ // Use event.code for layout-independent keys, including Space whose event.key
293
+ // is a literal whitespace character and cannot be represented cleanly in TOML.
294
+ const matches = /^(Key|Digit|Numpad|F\d|Arrow|Space$)/i.test(key)
295
+ ? event.code.toLowerCase() === key.toLowerCase()
296
+ : event.key.toLowerCase() === key.toLowerCase();
297
+ if (matches && debugKeys && event.type === 'keydown') {
298
+ showDebugDiagnostic(`shortcut matched action=${name} spec=${keybindingSpec(name)}`);
299
+ }
300
+ return matches;
301
+ }
302
+
303
+ // Produces the visible shortcut form used by menus and the Help panel.
304
+ function keybindingLabel(name) {
305
+ return keybindingSpec(name).replace(/\bMod\b/g, navigator.platform.toLowerCase().includes('mac') ? 'Cmd' : 'Ctrl');
306
+ }
307
+
308
+ // Returns the compact action-key label used in the constrained titlebar menu.
309
+ function keybindingActionLabel(name) {
310
+ const key = keybindingSpec(name).split('+').map((token) => token.trim()).filter(Boolean).pop() || '';
311
+ const physicalLetter = /^Key([A-Z])$/i.exec(key);
312
+ const physicalDigit = /^Digit([0-9])$/i.exec(key);
313
+ return physicalLetter?.[1].toLowerCase() || physicalDigit?.[1] || key.toLowerCase();
314
+ }
315
+
316
+ // Resolves the common prefix separately so each menu item can stay compact.
317
+ function keybindingPrefixLabel() {
318
+ const prefix = configuredKeybindingPrefix();
319
+ return prefix.replace(/\bMod\b/g, navigator.platform.toLowerCase().includes('mac') ? 'Cmd' : 'Ctrl');
320
+ }
321
+
322
+ // Keeps menu labels and accessibility metadata aligned with config.toml.
323
+ function applyKeybindingLabels() {
324
+ const items = [
325
+ [terminalLogToggleButton, 'logToggle', 'Log Start'],
326
+ [terminalLogShowButton, 'logShow', 'Log Show'],
327
+ [terminalBroadcastButton, 'broadcast', 'Broadcast'],
328
+ [terminalKillButton, 'kill', 'Kill'],
329
+ [terminalCopyButton, 'copy', 'Copy'],
330
+ [terminalPasteButton, 'paste', 'Paste'],
331
+ [newWindowButton, 'newWindow', 'New'],
332
+ [newWindowCwdButton, 'openCwd', 'New CWD'],
333
+ [arrangeWindowButton, 'tile', 'Tile'],
334
+ [closeAllWindowsButton, 'closeAll', 'Close All'],
335
+ [keyboardShortcutsHelpButton, 'help', 'Help'],
336
+ ];
337
+ for (const [element, action, label] of items) {
338
+ if (!element) {
339
+ continue;
340
+ }
341
+ const shortcut = keybindingLabel(action);
342
+ element.setAttribute('aria-keyshortcuts', shortcut);
343
+ if (element !== terminalLogToggleButton || element.dataset.active !== 'true') {
344
+ element.textContent = `${label} (${keybindingActionLabel(action)})`;
345
+ }
346
+ }
347
+ if (keybindingPrefixElement) {
348
+ keybindingPrefixElement.textContent = `Shortcut prefix: ${keybindingPrefixLabel()}`;
349
+ const configured = String(appConfig.keybindings?.prefix ?? '').trim();
350
+ keybindingPrefixElement.title = configured && !isValidKeybindingPrefix(configured)
351
+ ? `Invalid prefix \"${configured}\"; using ${keybindingPrefixLabel()}`
352
+ : '';
353
+ }
354
+ if (windowMenuToggleButton) {
355
+ windowMenuToggleButton.setAttribute('aria-keyshortcuts', `${keybindingLabel('menu')} ${keybindingLabel('logMenu')}`);
356
+ }
357
+ }
358
+
359
+ // Provides the renderer API shape expected by the rest of this file when the
360
+ // backend is injected by Tauri.
361
+ function installTauriApiAdapter() {
362
+ if (window.fpasoterm || !window.__TAURI__) {
363
+ return;
364
+ }
365
+
366
+ const { invoke } = window.__TAURI__.core;
367
+ const { listen } = window.__TAURI__.event;
368
+ window.fpasoterm = {
369
+ startTerminal: (size) => invoke('terminal_start', { size }),
370
+ writeTerminal: (data) => invoke('terminal_write', { data }),
371
+ killTerminal: () => invoke('terminal_kill'),
372
+ broadcastTerminal: (text, includeSync, targetInstanceIds, keyEvent) => invoke('terminal_broadcast', {
373
+ request: { text, includeSync, targetInstanceIds, keyEvent },
374
+ }),
375
+ terminalBroadcastTargets: () => invoke('terminal_broadcast_targets'),
376
+ resizeTerminal: (size) => invoke('terminal_resize', { size }),
377
+ startTerminalLog: (path) => invoke('terminal_log_start', { request: { path: path || null } }),
378
+ stopTerminalLog: () => invoke('terminal_log_stop'),
379
+ terminalLogStatus: () => invoke('terminal_log_status'),
380
+ listTerminalLogs: () => invoke('terminal_log_list'),
381
+ showTerminalLog: (path) => invoke('terminal_log_show', { request: { path: path || null } }),
382
+ clearTerminalLog: () => invoke('terminal_log_clear'),
383
+ deleteTerminalLog: (path) => invoke('terminal_log_delete', { request: { path: path || null } }),
384
+ onTerminalData: (callback) => {
385
+ return listen('terminal:data', (event) => callback(event.payload));
386
+ },
387
+ onTerminalBroadcastKey: (callback) => {
388
+ return listen('terminal:broadcast-key', (event) => callback(event.payload));
389
+ },
390
+ onTerminalExit: (callback) => {
391
+ return listen('terminal:exit', (event) => callback(event.payload?.exitCode ?? 0));
392
+ },
393
+ onDiagnosticEvent: (callback) => {
394
+ return listen('diagnostics:event', (event) => callback(event.payload));
395
+ },
396
+ copyDiagnostics: async () => {
397
+ const text = await invoke('diagnostics_copy');
398
+ return writeClipboardText(text);
399
+ },
400
+ getDiagnosticsPath: () => invoke('diagnostics_path'),
401
+ logDiagnostic: (message) => invoke('diagnostics_log', { message }),
402
+ readClipboard: () => invoke('clipboard_read'),
403
+ writeClipboard: (text) => invoke('clipboard_write', { text }),
404
+ openExternalUrl: (url) => invoke('open_external_url', { url }),
405
+ getAppVersion: () => invoke('app_version'),
406
+ checkForUpdate: () => invoke('update_check'),
407
+ getPluginCatalog: () => invoke('plugin_catalog'),
408
+ getTerminalCapabilities: () => invoke('terminal_capabilities'),
409
+ setTerminalEncoding: (encoding) => invoke('config_set_terminal_encoding', { encoding }),
410
+ getConfig: () => invoke('config_get'),
411
+ applyConfigPath: (path) => invoke('config_apply_path', { path }),
412
+ syncStatus: () => invoke('sync_status'),
413
+ syncClean: () => invoke('sync_clean'),
414
+ syncWriteDiagnostics: () => invoke('sync_write_diagnostics'),
415
+ getSshfsStatus: () => invoke('sshfs_status'),
416
+ mountSshfs: (request) => invoke('sshfs_mount', { request }),
417
+ unmountSshfs: (mountName) => invoke('sshfs_unmount', { mountName }),
418
+ unmountAllSshfs: () => invoke('sshfs_unmount_all'),
419
+ forgetSshfs: (mountName) => invoke('sshfs_forget', { mountName }),
420
+ closeWindow: () => invoke('window_close'),
421
+ minimizeWindow: () => invoke('window_minimize'),
422
+ toggleMaximizeWindow: () => invoke('window_toggle_maximize'),
423
+ newWindow: () => invoke('window_new'),
424
+ newWindowAtCwd: (cwd, host) => invoke('window_new_at_cwd', { cwd, host: host || null }),
425
+ arrangeWindows: (screen) => invoke('window_arrange', { screen }),
426
+ closeAllWindows: () => invoke('window_close_all'),
427
+ confirmCloseAllWindows: () => invoke('window_confirm_close_all'),
428
+ startWindowDrag: () => invoke('window_start_drag'),
429
+ startWindowResizeDrag: (direction) => window.__TAURI__.window.getCurrentWindow().startResizeDragging(direction),
430
+ saveWindowBounds: () => invoke('window_save_bounds'),
431
+ getWindowBounds: () => invoke('window_get_bounds'),
432
+ setWindowBounds: (bounds) => invoke('window_set_bounds', { bounds }),
433
+ };
434
+ }
435
+
436
+ // Mirrors renderer exceptions to the main diagnostics pipeline via console capture.
437
+ window.addEventListener('error', (event) => {
438
+ console.error(`renderer error: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`);
439
+ });
440
+
441
+ // Mirrors failed async work to diagnostics instead of failing silently.
442
+ window.addEventListener('unhandledrejection', (event) => {
443
+ console.error(`renderer unhandled rejection: ${event.reason}`);
444
+ });
445
+
446
+ // Shows a compact titlebar indicator for managed SSHFS mounts without exposing remote credentials.
447
+ async function refreshSshfsMountStatus() {
448
+ if (!sshfsMountStatusElement || !window.fpasoterm.getSshfsStatus) return;
449
+ try {
450
+ const status = await window.fpasoterm.getSshfsStatus();
451
+ const count = Array.isArray(status.activeMountNames) ? status.activeMountNames.length : 0;
452
+ sshfsMountStatusElement.hidden = count === 0;
453
+ sshfsMountStatusElement.textContent = `SSHFS (${count})`;
454
+ } catch (_) {
455
+ sshfsMountStatusElement.hidden = true;
456
+ }
457
+ }
458
+
459
+ // Fits xterm.js to the available element size and resizes the PTY.
460
+ function fitAndResize() {
461
+ if (!fitAddon || !term) {
462
+ return;
463
+ }
464
+
465
+ const beforeCols = term.cols;
466
+ const beforeRows = term.rows;
467
+ fitAddon.fit();
468
+ const size = terminalPtySize();
469
+ showDebugDiagnostic(
470
+ `terminal fit cols=${beforeCols}->${size.cols} rows=${beforeRows}->${size.rows} ` +
471
+ `pixels=${size.pixelWidth}x${size.pixelHeight}`,
472
+ );
473
+ window.fpasoterm.resizeTerminal(size).catch((error) => {
474
+ showDiagnostic(`terminal resize failed: ${error}`);
475
+ });
476
+ }
477
+
478
+ // Reports both the xterm grid and its rendered pixel extent to the PTY.
479
+ // Kitty-aware applications use the pixel fields from TIOCSWINSZ to size images.
480
+ function terminalPtySize() {
481
+ const rect = terminalElement?.getBoundingClientRect();
482
+ return {
483
+ cols: Math.max(1, term?.cols || 80),
484
+ rows: Math.max(1, term?.rows || 24),
485
+ pixelWidth: Math.min(65535, Math.max(1, Math.round(rect?.width || 1))),
486
+ pixelHeight: Math.min(65535, Math.max(1, Math.round(rect?.height || 1))),
487
+ };
488
+ }
489
+
490
+ // Coalesces rapid webview resize events so shells do not redraw prompts repeatedly.
491
+ function scheduleFitAndResize() {
492
+ if (terminalResizeTimer) {
493
+ clearTimeout(terminalResizeTimer);
494
+ }
495
+ terminalResizeTimer = setTimeout(() => {
496
+ fitAndResize();
497
+ }, 80);
498
+ }
499
+
500
+ // Re-runs fit after layout-affecting runtime changes have settled in the webview.
501
+ function scheduleDeferredFitAndResize() {
502
+ scheduleFitAndResize();
503
+ if (terminalDeferredResizeTimer) {
504
+ clearTimeout(terminalDeferredResizeTimer);
505
+ }
506
+ terminalDeferredResizeTimer = setTimeout(async () => {
507
+ await afterNextPaint();
508
+ fitAndResize();
509
+ }, 220);
510
+ }
511
+
512
+ // Waits for the webview to finish layout before measuring the terminal.
513
+ function afterNextPaint() {
514
+ return new Promise((resolve) => {
515
+ requestAnimationFrame(() => {
516
+ requestAnimationFrame(resolve);
517
+ });
518
+ });
519
+ }
520
+
521
+ // Persists the current window size shortly after resize events settle.
522
+ function scheduleWindowStateSave() {
523
+ if (windowStateSaveTimer) {
524
+ clearTimeout(windowStateSaveTimer);
525
+ }
526
+ windowStateSaveTimer = setTimeout(() => {
527
+ window.fpasoterm.saveWindowBounds?.().catch((error) => {
528
+ showDiagnostic(`window state save failed: ${error}`);
529
+ });
530
+ }, 250);
531
+ }
532
+
533
+ // Focuses both xterm.js and its hidden helper textarea used for IME input.
534
+ function focusTerminalInput() {
535
+ if (!terminalElement || !term) {
536
+ return;
537
+ }
538
+ term.focus();
539
+
540
+ const textarea = terminalElement.querySelector('.xterm-helper-textarea');
541
+ if (textarea) {
542
+ textarea.focus({ preventScroll: true });
543
+ }
544
+ }
545
+
546
+ // Some supported WebViews keep marked text exclusively in xterm's hidden
547
+ // textarea. The overlay is visual-only and does not affect PTY input.
548
+ function needsImePreeditOverlay() {
549
+ // macOS WebKit owns marked-text painting. A separate overlay can outlive a
550
+ // composition there, so retain the fallback only for Windows/Linux webviews.
551
+ return !/Mac/i.test(navigator.platform || navigator.userAgent || '');
552
+ }
553
+
554
+ // Keeps a platform-independent conversion indicator visible when a WebView
555
+ // declines to paint the terminal-area overlay.
556
+ function updateImeStatus(text) {
557
+ if (!imeStatusElement) {
558
+ return;
559
+ }
560
+ const value = String(text || '');
561
+ imeStatusElement.textContent = value ? `IME: ${value}` : '';
562
+ imeStatusElement.hidden = !value;
563
+ }
564
+
565
+ // Shows browser-owned marked IME text over the terminal without sending it to
566
+ // the PTY. Some macOS WebKit builds do not paint xterm's hidden textarea text.
567
+ function showImePreedit(text, showComposingFallback = false) {
568
+ const value = String(text || '') || (showComposingFallback ? 'composing...' : '');
569
+ if (!needsImePreeditOverlay() || !imePreeditElement || !value || !terminalElement) {
570
+ return;
571
+ }
572
+ document.body.classList.add('ime-preedit-overlay');
573
+ if (imePreeditClearTimer) {
574
+ clearTimeout(imePreeditClearTimer);
575
+ imePreeditClearTimer = null;
576
+ }
577
+ const terminalRect = terminalElement.getBoundingClientRect();
578
+ const textarea = terminalElement.querySelector('.xterm-helper-textarea');
579
+ const textareaRect = textarea?.getBoundingClientRect();
580
+ const fontSize = Number(appConfig?.terminal?.fontSize) || fallbackConfig.terminal.fontSize;
581
+ const lineHeight = Number(appConfig?.terminal?.lineHeight) || fallbackConfig.terminal.lineHeight;
582
+ const withinTerminal = textareaRect
583
+ && textareaRect.left >= terminalRect.left
584
+ && textareaRect.left < terminalRect.right
585
+ && textareaRect.top >= terminalRect.top
586
+ && textareaRect.top < terminalRect.bottom;
587
+ const left = withinTerminal ? textareaRect.left : terminalRect.left + 4;
588
+ const top = withinTerminal ? textareaRect.top : terminalRect.bottom - fontSize * lineHeight - 8;
589
+ imePreeditElement.style.left = `${Math.round(Math.max(terminalRect.left + 2, left))}px`;
590
+ imePreeditElement.style.top = `${Math.round(Math.max(terminalRect.top + 2, top))}px`;
591
+ imePreeditElement.style.fontFamily = appConfig?.terminal?.fontFamily || fallbackConfig.terminal.fontFamily;
592
+ imePreeditElement.style.fontSize = `${fontSize}px`;
593
+ imePreeditElement.style.lineHeight = String(lineHeight);
594
+ imePreeditElement.textContent = value;
595
+ imePreeditElement.hidden = false;
596
+ updateImeStatus(value);
597
+ }
598
+
599
+ // Clears the visual-only preedit overlay after the committed terminal text can paint.
600
+ function clearImePreedit(delay = 0) {
601
+ if (!imePreeditElement) {
602
+ return;
603
+ }
604
+ if (imePreeditClearTimer) {
605
+ clearTimeout(imePreeditClearTimer);
606
+ }
607
+ if (delay <= 0) {
608
+ imePreeditClearTimer = null;
609
+ imePreeditElement.hidden = true;
610
+ imePreeditElement.textContent = '';
611
+ document.body.classList.remove('ime-preedit-overlay');
612
+ updateImeStatus('');
613
+ return;
614
+ }
615
+ imePreeditClearTimer = setTimeout(() => {
616
+ imePreeditClearTimer = null;
617
+ imePreeditElement.hidden = true;
618
+ imePreeditElement.textContent = '';
619
+ document.body.classList.remove('ime-preedit-overlay');
620
+ updateImeStatus('');
621
+ }, delay);
622
+ }
623
+
624
+ // Stores the latest in-progress composition text from the helper textarea.
625
+ function trackCompositionUpdate(event) {
626
+ const value = event.data || event.target?.value || '';
627
+ showDebugDiagnostic(`renderer ime compositionupdate data=${JSON.stringify(event.data || '')} value=${JSON.stringify(event.target?.value || '')}`);
628
+ if (value) {
629
+ showImePreedit(value);
630
+ }
631
+ }
632
+
633
+ // Records native composition ordering without changing browser or xterm input.
634
+ function traceImeEvent(name, event) {
635
+ showDebugDiagnostic(`renderer ime ${name} data=${JSON.stringify(event.data || '')} value=${JSON.stringify(event.target?.value || '')} inputType=${event.inputType || ''}`);
636
+ }
637
+
638
+ // Retains a short trace window after commit so the next ordinary key can be
639
+ // correlated with delayed composition delivery without changing either input.
640
+ function extendImeTraceWindow() {
641
+ imeTraceUntil = Date.now() + 800;
642
+ }
643
+
644
+ // ChromeOS keeps committed text in xterm's hidden helper textarea. A new
645
+ // composition has not inserted its marked text at compositionstart, so clear
646
+ // the stale helper value there without changing terminal or PTY text.
647
+ function clearStaleImeTextareaAtCompositionStart(textarea) {
648
+ if (!/Linux/.test(navigator.platform || navigator.userAgent || '') || !textarea.value) {
649
+ return;
650
+ }
651
+ const staleValue = textarea.value;
652
+ textarea.value = '';
653
+ showDebugDiagnostic(`renderer ime cleared stale helper at compositionstart value=${JSON.stringify(staleValue)}`);
654
+ }
655
+
656
+ // Some Windows WebView2 builds dispatch composition events above xterm's
657
+ // hidden textarea. Observe the document capture path for display only.
658
+ function isTerminalImeEvent(event) {
659
+ return Boolean(
660
+ terminalElement?.contains(event.target)
661
+ || terminalElement?.contains(document.activeElement),
662
+ );
663
+ }
664
+
665
+ function installImeVisualFallback() {
666
+ document.addEventListener('keydown', (event) => {
667
+ if (isTerminalImeEvent(event) && (event.isComposing || event.keyCode === 229)) {
668
+ traceImeEvent('document-keydown', event);
669
+ showImePreedit(event.target?.value || document.activeElement?.value, true);
670
+ }
671
+ }, true);
672
+ document.addEventListener('compositionstart', (event) => {
673
+ if (isTerminalImeEvent(event)) {
674
+ traceImeEvent('document-compositionstart', event);
675
+ showImePreedit(event.data || event.target?.value, true);
676
+ }
677
+ }, true);
678
+ document.addEventListener('compositionupdate', (event) => {
679
+ if (isTerminalImeEvent(event)) {
680
+ traceImeEvent('document-compositionupdate', event);
681
+ showImePreedit(event.data || event.target?.value, true);
682
+ }
683
+ }, true);
684
+ document.addEventListener('compositionend', (event) => {
685
+ if (isTerminalImeEvent(event)) {
686
+ traceImeEvent('document-compositionend', event);
687
+ showImePreedit(event.data || event.target?.value, true);
688
+ clearImePreedit(180);
689
+ }
690
+ }, true);
691
+ }
692
+
693
+ // Observes composition events for visual feedback only. Do not suppress,
694
+ // replay, replace, or directly commit them: xterm.js owns normal PTY input.
695
+ function installCompositionObserver() {
696
+ const textarea = terminalElement.querySelector('.xterm-helper-textarea');
697
+ if (!textarea) {
698
+ return;
699
+ }
700
+
701
+ textarea.addEventListener('keydown', (event) => {
702
+ if (event.isComposing || event.keyCode === 229 || Date.now() < imeTraceUntil) {
703
+ traceImeEvent('keydown', event);
704
+ }
705
+ if (event.isComposing || event.keyCode === 229) {
706
+ showImePreedit(textarea.value, true);
707
+ }
708
+ }, true);
709
+
710
+ textarea.addEventListener('compositionstart', (event) => {
711
+ extendImeTraceWindow();
712
+ traceImeEvent('compositionstart', event);
713
+ clearImePreedit();
714
+ clearStaleImeTextareaAtCompositionStart(textarea);
715
+ showImePreedit(event.target?.value, true);
716
+ }, true);
717
+ textarea.addEventListener('compositionupdate', (event) => {
718
+ extendImeTraceWindow();
719
+ trackCompositionUpdate(event);
720
+ }, true);
721
+ textarea.addEventListener('beforeinput', (event) => {
722
+ const isCompositionInput = event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromComposition';
723
+ if (isCompositionInput || Date.now() < imeTraceUntil) {
724
+ traceImeEvent('beforeinput', event);
725
+ }
726
+ if (event.inputType === 'insertCompositionText') {
727
+ trackCompositionUpdate(event);
728
+ }
729
+ }, true);
730
+ textarea.addEventListener('compositionend', (event) => {
731
+ extendImeTraceWindow();
732
+ traceImeEvent('compositionend', event);
733
+ const committedValue = event.target?.value || event.data || '';
734
+ showImePreedit(event.data || committedValue, true);
735
+ clearImePreedit(180);
736
+ });
737
+ textarea.addEventListener('input', (event) => {
738
+ const isCompositionInput = event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromComposition';
739
+ if (isCompositionInput || Date.now() < imeTraceUntil) {
740
+ traceImeEvent('input', event);
741
+ }
742
+ }, true);
743
+ }
744
+
745
+ // Writes a diagnostic line to the optional in-window diagnostics panel.
746
+ function appendDiagnosticLine(message) {
747
+ if (!debugKeys) {
748
+ return;
749
+ }
750
+
751
+ diagnosticLines.push(message);
752
+ if (diagnosticLines.length > 80) {
753
+ diagnosticLines.shift();
754
+ }
755
+ if (diagnosticsPanelMode !== 'terminal-log') {
756
+ diagnosticsPanelMode = 'diagnostics';
757
+ setTerminalLogPickerVisible(false);
758
+ }
759
+ showDiagnosticsTextArea();
760
+ if (diagnosticsTitleElement) {
761
+ diagnosticsTitleElement.textContent = 'Diagnostics';
762
+ }
763
+ diagnosticsPanel.hidden = false;
764
+ diagnosticsElement.value = diagnosticLines.join('\n');
765
+ diagnosticsElement.scrollTop = diagnosticsElement.scrollHeight;
766
+ }
767
+
768
+ // Restores the shared panel's regular text body after another diagnostics view.
769
+ function showDiagnosticsTextArea() {
770
+ diagnosticsElement.hidden = false;
771
+ setTerminalEncodingControlsVisible(false);
772
+ if (checkForUpdatesButton) {
773
+ checkForUpdatesButton.hidden = true;
774
+ }
775
+ if (fontGlyphPreviewElement) {
776
+ fontGlyphPreviewElement.hidden = true;
777
+ }
778
+ if (terminalCapabilityPreviewElement) {
779
+ terminalCapabilityPreviewElement.hidden = true;
780
+ }
781
+ }
782
+
783
+ // Shows debug messages locally and mirrors renderer diagnostics to the backend.
784
+ function showDiagnostic(message) {
785
+ console.error(message);
786
+ window.fpasoterm?.logDiagnostic?.(message).catch(() => {});
787
+ appendDiagnosticLine(message);
788
+ scheduleSyncDiagnosticsWrite();
789
+ }
790
+
791
+ // Emits verbose diagnostics only when key/debug logging is enabled.
792
+ function showDebugDiagnostic(message) {
793
+ if (debugKeys) {
794
+ showDiagnostic(message);
795
+ }
796
+ }
797
+
798
+ // Converts control characters into visible markers for diagnostics output.
799
+ function printableDiagnosticData(data) {
800
+ return data
801
+ .replace(/\x1b/g, '<ESC>')
802
+ .replace(/\r/g, '<CR>')
803
+ .replace(/\n/g, '<LF>\n')
804
+ .replace(/\t/g, '<TAB>');
805
+ }
806
+
807
+ // Composes decomposed kana marks without changing half-width kana characters.
808
+ function normalizeJapaneseTerminalText(data) {
809
+ return String(data || '').normalize('NFC');
810
+ }
811
+
812
+ function queueTerminalOutput(data) {
813
+ const normalizedData = normalizeJapaneseTerminalText(data);
814
+ showDebugDiagnostic(`renderer terminal data bytes=${normalizedData.length} preview=${printableDiagnosticData(normalizedData).slice(0, 160)}`);
815
+ processRuntimeOsc(normalizedData);
816
+ mirrorTerminalData(normalizedData);
817
+ term.write(normalizedData, () => {
818
+ removeXtermVisualOverlays();
819
+ logXtermCanvasDiagnostics();
820
+ logXtermTextDiagnostics();
821
+ showDebugDiagnostic(`renderer terminal write parsed bytes=${normalizedData.length}`);
822
+ schedulePluginsReadyAfterTerminalOutput(180);
823
+ });
824
+ }
825
+
826
+ // Mirrors PTY output outside xterm.js so renderer delivery can be verified.
827
+ function mirrorTerminalData(data) {
828
+ if (!debugKeys || !terminalMirrorElement) {
829
+ return;
830
+ }
831
+
832
+ terminalMirrorText += printableDiagnosticData(data);
833
+ if (terminalMirrorText.length > 6000) {
834
+ terminalMirrorText = terminalMirrorText.slice(-6000);
835
+ }
836
+ terminalMirrorElement.hidden = false;
837
+ terminalMirrorElement.textContent = terminalMirrorText;
838
+ terminalMirrorElement.scrollTop = terminalMirrorElement.scrollHeight;
839
+ }
840
+
841
+ // Writes a visible startup error into the terminal area when the backend fails.
842
+ function showTerminalError(message) {
843
+ console.error(message);
844
+ if (term) {
845
+ term.writeln('');
846
+ term.writeln(message);
847
+ return;
848
+ }
849
+
850
+ terminalElement.textContent = message;
851
+ terminalElement.classList.add('terminal-error');
852
+ }
853
+
854
+ // Sends terminal input as emitted by xterm.js. IME data is intentionally not
855
+ // altered here so platform-specific input can be observed and debugged.
856
+ function sendTerminalInput(data, source = 'input') {
857
+ showDebugDiagnostic(`renderer terminal ${source} bytes=${data.length} data=${JSON.stringify(data)} codepoints=${Array.from(data).map((character) => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`).join(',')}`);
858
+ window.fpasoterm.writeTerminal(data).catch((error) => {
859
+ showTerminalError(`terminal write failed: ${error}`);
860
+ });
861
+ }
862
+
863
+ // Converts OS clipboard text into terminal paste input.
864
+ function normalizePasteText(text) {
865
+ return String(text || '').replace(/\r\n/g, '\r').replace(/\n/g, '\r');
866
+ }
867
+
868
+ // Uses the native pasteboard on macOS because it preserves UTF-8 text more
869
+ // reliably than some WebKit clipboard bridges for Japanese filenames.
870
+ function isMacPlatform() {
871
+ return /Mac/i.test(navigator.platform || navigator.userAgent || '');
872
+ }
873
+
874
+ // Reads the OS clipboard in a user-triggered event and sends it to the shell.
875
+ // WebKitGTK owns the ChromeOS clipboard integration, so prefer its API before
876
+ // shell helpers such as wl-paste. A successful helper with an empty selection
877
+ // must not hide text that the WebView can read.
878
+ async function pasteClipboardToTerminal() {
879
+ let text = '';
880
+ const errors = [];
881
+ const readers = isMacPlatform()
882
+ ? [
883
+ async () => window.fpasoterm.readClipboard(),
884
+ async () => navigator.clipboard?.readText?.() || '',
885
+ ]
886
+ : [
887
+ async () => navigator.clipboard?.readText?.() || '',
888
+ async () => window.fpasoterm.readClipboard(),
889
+ ];
890
+ for (const read of readers) {
891
+ if (text) {
892
+ break;
893
+ }
894
+ try {
895
+ text = await read();
896
+ } catch (error) {
897
+ errors.push(String(error));
898
+ }
899
+ }
900
+
901
+ if (!text) {
902
+ if (errors.length) {
903
+ showDiagnostic(`terminal paste failed: ${errors.join('; ')}`);
904
+ }
905
+ showDiagnostic('terminal paste skipped: clipboard is empty');
906
+ return;
907
+ }
908
+ sendTerminalInput(normalizePasteText(text), 'paste');
909
+ }
910
+
911
+ // Writes text through the WebView clipboard path used by the host desktop.
912
+ async function writeBrowserClipboardText(text) {
913
+ if (!navigator.clipboard) {
914
+ throw new Error('navigator.clipboard is unavailable');
915
+ }
916
+ if (typeof navigator.clipboard.write === 'function' && typeof window.ClipboardItem === 'function') {
917
+ const item = new window.ClipboardItem({
918
+ 'text/plain': new Blob([text], { type: 'text/plain' }),
919
+ });
920
+ await navigator.clipboard.write([item]);
921
+ return;
922
+ }
923
+ await navigator.clipboard.writeText(text);
924
+ }
925
+
926
+ // Writes text to both host WebView and backend clipboard paths when possible.
927
+ async function writeClipboardText(text) {
928
+ const value = String(text || '');
929
+ if (!value) {
930
+ return 0;
931
+ }
932
+
933
+ const errors = [];
934
+ let wrote = false;
935
+ if (isMacPlatform()) {
936
+ try {
937
+ await window.fpasoterm.writeClipboard(value);
938
+ return value.length;
939
+ } catch (error) {
940
+ throw new Error(`macOS pasteboard write failed: ${error}`);
941
+ }
942
+ }
943
+ try {
944
+ await writeBrowserClipboardText(value);
945
+ wrote = true;
946
+ } catch (browserError) {
947
+ errors.push(`browser clipboard: ${browserError}`);
948
+ showDiagnostic(`browser clipboard write failed: ${browserError}`);
949
+ }
950
+
951
+ try {
952
+ await window.fpasoterm.writeClipboard(value);
953
+ wrote = true;
954
+ } catch (backendError) {
955
+ errors.push(`backend clipboard: ${backendError}`);
956
+ showDiagnostic(`backend clipboard write failed: ${backendError}`);
957
+ }
958
+
959
+ if (!wrote) {
960
+ throw new Error(errors.join('; '));
961
+ }
962
+ return value.length;
963
+ }
964
+
965
+ // Returns the current selected terminal text, if any.
966
+ function selectedTerminalText() {
967
+ if (!term || typeof term.hasSelection !== 'function' || !term.hasSelection()) {
968
+ return '';
969
+ }
970
+ return term.getSelection();
971
+ }
972
+
973
+ // Returns diagnostics/log text when the panel has a focused text selection.
974
+ function selectedDiagnosticsClipboardText() {
975
+ if (diagnosticsPanel.hidden) {
976
+ return '';
977
+ }
978
+ const start = diagnosticsElement.selectionStart;
979
+ const end = diagnosticsElement.selectionEnd;
980
+ if (!Number.isInteger(start) || !Number.isInteger(end) || end <= start) {
981
+ return '';
982
+ }
983
+ return diagnosticsElement.value.slice(start, end);
984
+ }
985
+
986
+ // Selects the text source that should be copied by a user copy gesture.
987
+ function selectedClipboardText() {
988
+ return selectedDiagnosticsClipboardText() || selectedTerminalText();
989
+ }
990
+
991
+ // Copies the current xterm.js selection to the OS clipboard.
992
+ async function copyTerminalSelection() {
993
+ const text = selectedClipboardText();
994
+ if (!text) {
995
+ showDiagnostic('terminal copy skipped: no selection');
996
+ return 0;
997
+ }
998
+ const copiedLength = await writeClipboardText(text);
999
+ showDiagnostic(`selection copied bytes=${copiedLength}`);
1000
+ return copiedLength;
1001
+ }
1002
+
1003
+ // Copies a terminal link without opening it. This keeps OSC 8 URLs and paths
1004
+ // usable in remote sessions without delegating untrusted output to the browser.
1005
+ async function copyTerminalLink(text, kind = 'link') {
1006
+ const value = String(text || '').trim();
1007
+ if (!value) {
1008
+ return;
1009
+ }
1010
+ try {
1011
+ await writeClipboardText(value);
1012
+ showDiagnostic(`${kind} copied: ${value}`);
1013
+ } catch (error) {
1014
+ showDiagnostic(`${kind} copy failed: ${error}`);
1015
+ }
1016
+ }
1017
+
1018
+ // Only HTTP(S) links can reach the explicit external-browser action.
1019
+ function isExternalHttpUrl(value) {
1020
+ try {
1021
+ const url = new URL(String(value || '').trim());
1022
+ return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.host);
1023
+ } catch (_) {
1024
+ return false;
1025
+ }
1026
+ }
1027
+
1028
+ // Keeps keyboard navigation inside the confirmation dialog.
1029
+ function terminalUrlFocusItems() {
1030
+ if (!terminalUrlDialog || terminalUrlDialog.hidden) {
1031
+ return [];
1032
+ }
1033
+ return [terminalUrlCopyButton, terminalUrlOpenButton, terminalUrlCancelButton]
1034
+ .filter((element) => element && !element.hidden && !element.disabled);
1035
+ }
1036
+
1037
+ function closeTerminalUrlDialog() {
1038
+ if (terminalUrlDialog) {
1039
+ terminalUrlDialog.hidden = true;
1040
+ }
1041
+ pendingTerminalUrl = '';
1042
+ focusTerminalInput();
1043
+ }
1044
+
1045
+ // Opens a confirmation dialog for terminal-provided URLs instead of navigating automatically.
1046
+ function showTerminalUrlDialog(value, kind = 'URL') {
1047
+ const url = String(value || '').trim();
1048
+ if (!isExternalHttpUrl(url) || !terminalUrlDialog || !terminalUrlMessageElement) {
1049
+ copyTerminalLink(url, kind);
1050
+ return;
1051
+ }
1052
+ pendingTerminalUrl = url;
1053
+ const openingEnabled = oscSecurityConfig().osc8Open === true;
1054
+ terminalUrlMessageElement.textContent = openingEnabled
1055
+ ? `${kind} from terminal output:\n${url}\n\nCopy it, or explicitly open it in the external browser.`
1056
+ : `${kind} from terminal output:\n${url}\n\nExternal opening is disabled by [security] osc8Open = false. You can still copy the URL.`;
1057
+ terminalUrlOpenButton.hidden = !openingEnabled;
1058
+ terminalUrlDialog.hidden = false;
1059
+ terminalUrlCopyButton.focus({ preventScroll: true });
1060
+ }
1061
+
1062
+ async function openPendingTerminalUrl() {
1063
+ const url = pendingTerminalUrl;
1064
+ if (!url || oscSecurityConfig().osc8Open !== true) {
1065
+ showDiagnostic('external URL open skipped because security.osc8Open is disabled');
1066
+ return;
1067
+ }
1068
+ try {
1069
+ await window.fpasoterm.openExternalUrl(url);
1070
+ showDiagnostic(`external URL opened after confirmation: ${url}`);
1071
+ closeTerminalUrlDialog();
1072
+ } catch (error) {
1073
+ showDiagnostic(`external URL open failed: ${error}`);
1074
+ }
1075
+ }
1076
+
1077
+ // Registers local absolute and home-relative paths that are not covered by
1078
+ // the URL addon. The terminal buffer uses 1-based link positions.
1079
+ function installTerminalPathLinks() {
1080
+ if (!term || typeof term.registerLinkProvider !== 'function') {
1081
+ return;
1082
+ }
1083
+ const pathPattern = /(?:~\/|\/(?:[^\s'"`<>()[\]{}|]+)|[A-Za-z]:\\(?:[^\s'"`<>()[\]{}|]+))/g;
1084
+ term.registerLinkProvider({
1085
+ provideLinks(y, callback) {
1086
+ const line = term.buffer.active.getLine(y - 1);
1087
+ const lineText = line?.translateToString(true) || '';
1088
+ const links = [];
1089
+ for (const match of lineText.matchAll(pathPattern)) {
1090
+ const text = match[0].replace(/[),;]+$/, '');
1091
+ if (!text) {
1092
+ continue;
1093
+ }
1094
+ const start = Number(match.index || 0);
1095
+ links.push({
1096
+ text,
1097
+ range: {
1098
+ start: { x: start + 1, y },
1099
+ end: { x: start + text.length, y },
1100
+ },
1101
+ activate: () => copyTerminalLink(text, 'path'),
1102
+ });
1103
+ }
1104
+ callback(links);
1105
+ },
1106
+ });
1107
+ }
1108
+
1109
+ // Installs plain URL and OSC 8 handlers. Browser navigation is always explicit.
1110
+ function installTerminalLinkHandlers() {
1111
+ if (!term) {
1112
+ return;
1113
+ }
1114
+ if (window.WebLinksAddon?.WebLinksAddon) {
1115
+ const webLinksAddon = new window.WebLinksAddon.WebLinksAddon((_, uri) => {
1116
+ showTerminalUrlDialog(uri, 'URL');
1117
+ });
1118
+ term.loadAddon(webLinksAddon);
1119
+ } else {
1120
+ showDiagnostic('xterm web links addon is unavailable');
1121
+ }
1122
+ installTerminalPathLinks();
1123
+ }
1124
+
1125
+ // Returns true for editable UI controls other than xterm's hidden IME textarea.
1126
+ // The xterm textarea must be treated as terminal focus so paste reaches the PTY.
1127
+ function isNonTerminalEditableControl(element) {
1128
+ const terminalTextarea = terminalElement.querySelector('.xterm-helper-textarea');
1129
+ return (
1130
+ Boolean(element) &&
1131
+ element !== terminalTextarea &&
1132
+ element.matches?.('input, textarea, select, button, [contenteditable="true"]')
1133
+ );
1134
+ }
1135
+
1136
+ // Installs explicit paste handling for desktop webviews where xterm defaults can be skipped.
1137
+ function installTerminalPasteHandlers() {
1138
+ let terminalPasteFallbackTimer = null;
1139
+
1140
+ // Keep Ctrl+C available when a terminal graphics layer has displaced xterm focus.
1141
+ // Normal xterm input handles it while the helper textarea owns focus, so this
1142
+ // capture listener only provides the otherwise-unreachable fallback path.
1143
+ window.addEventListener('keydown', (event) => {
1144
+ const isInterrupt =
1145
+ event.ctrlKey &&
1146
+ !event.shiftKey &&
1147
+ !event.altKey &&
1148
+ !event.metaKey &&
1149
+ event.key.toLowerCase() === 'c';
1150
+ if (!isInterrupt) {
1151
+ return;
1152
+ }
1153
+
1154
+ const textarea = terminalElement.querySelector('.xterm-helper-textarea');
1155
+ if (document.activeElement === textarea) {
1156
+ return;
1157
+ }
1158
+
1159
+ const activeElement = document.activeElement;
1160
+ if (isNonTerminalEditableControl(activeElement)) {
1161
+ return;
1162
+ }
1163
+
1164
+ event.preventDefault();
1165
+ event.stopImmediatePropagation();
1166
+ sendTerminalInput('\x03', 'interrupt fallback');
1167
+ setTimeout(focusTerminalInput, 0);
1168
+ }, true);
1169
+
1170
+ // Let the native WebView create a paste event first. Its clipboardData is the
1171
+ // only reliable ChromeOS shared-clipboard path. Some WebKitGTK builds do not
1172
+ // dispatch it for Ctrl+Shift+V, so use the API fallback only after a short wait.
1173
+ window.addEventListener('keydown', (event) => {
1174
+ const isPaste = matchesKeybinding(event, 'paste');
1175
+ if (!isPaste) {
1176
+ return;
1177
+ }
1178
+ const activeElement = document.activeElement;
1179
+ if (isNonTerminalEditableControl(activeElement)) {
1180
+ return;
1181
+ }
1182
+ if (terminalPasteFallbackTimer) {
1183
+ clearTimeout(terminalPasteFallbackTimer);
1184
+ }
1185
+ terminalPasteFallbackTimer = setTimeout(() => {
1186
+ terminalPasteFallbackTimer = null;
1187
+ pasteClipboardToTerminal().catch((error) => {
1188
+ showDiagnostic(`terminal paste fallback failed: ${error}`);
1189
+ });
1190
+ focusTerminalInput();
1191
+ }, 120);
1192
+ }, true);
1193
+
1194
+ const handleTerminalPaste = (event) => {
1195
+ if (terminalPasteFallbackTimer) {
1196
+ clearTimeout(terminalPasteFallbackTimer);
1197
+ terminalPasteFallbackTimer = null;
1198
+ }
1199
+ const activeElement = document.activeElement;
1200
+ if (isNonTerminalEditableControl(activeElement)) {
1201
+ return;
1202
+ }
1203
+ const text = event.clipboardData?.getData('text/plain') || '';
1204
+ if (!text) {
1205
+ // Some WebKitGTK clipboard events intentionally omit clipboardData.
1206
+ // Read through the same browser-first fallback path as the keyboard shortcut.
1207
+ event.preventDefault();
1208
+ event.stopImmediatePropagation();
1209
+ pasteClipboardToTerminal().catch((error) => {
1210
+ showDiagnostic(`terminal paste failed: ${error}`);
1211
+ });
1212
+ return;
1213
+ }
1214
+ event.preventDefault();
1215
+ // xterm.js also listens for paste below this capture listener. The terminal
1216
+ // must receive the native payload exactly once through sendTerminalInput().
1217
+ event.stopImmediatePropagation();
1218
+ sendTerminalInput(normalizePasteText(text), 'paste');
1219
+ };
1220
+ // Capture at the window so paste still works if a visual layer displaced focus.
1221
+ window.addEventListener('paste', handleTerminalPaste, true);
1222
+
1223
+ terminalElement.addEventListener('contextmenu', (event) => {
1224
+ event.preventDefault();
1225
+ if (term?.hasSelection?.()) {
1226
+ copyTerminalSelection().catch((error) => {
1227
+ showDiagnostic(`terminal context copy failed: ${error}`);
1228
+ });
1229
+ return;
1230
+ }
1231
+ pasteClipboardToTerminal().catch((error) => {
1232
+ showDiagnostic(`terminal context paste failed: ${error}`);
1233
+ });
1234
+ });
1235
+
1236
+ window.addEventListener('keydown', (event) => {
1237
+ const isNewWindowShortcut = matchesKeybinding(event, 'newWindow');
1238
+ if (isNewWindowShortcut) {
1239
+ event.preventDefault();
1240
+ event.stopPropagation();
1241
+ window.fpasoterm.newWindow?.().catch((error) => showDiagnostic(`new window failed: ${error}`));
1242
+ return;
1243
+ }
1244
+
1245
+ if (matchesKeybinding(event, 'openCwd')) {
1246
+ event.preventDefault();
1247
+ event.stopPropagation();
1248
+ openWindowAtCurrentDirectory();
1249
+ return;
1250
+ }
1251
+
1252
+ const isArrangeShortcut = matchesKeybinding(event, 'tile');
1253
+ if (isArrangeShortcut) {
1254
+ event.preventDefault();
1255
+ event.stopPropagation();
1256
+ window.fpasoterm.arrangeWindows?.(getAvailableScreenBounds())
1257
+ .then((message) => showDiagnostic(message))
1258
+ .catch((error) => showDiagnostic(`window arrange failed: ${error}`));
1259
+ return;
1260
+ }
1261
+
1262
+ const isBroadcastShortcut = matchesKeybinding(event, 'broadcast');
1263
+ if (isBroadcastShortcut) {
1264
+ event.preventDefault();
1265
+ event.stopPropagation();
1266
+ openTerminalBroadcastDialog().catch((error) => showDiagnostic(`terminal broadcast dialog failed: ${error}`));
1267
+ return;
1268
+ }
1269
+
1270
+ const isKillShortcut = matchesKeybinding(event, 'kill');
1271
+ if (isKillShortcut) {
1272
+ event.preventDefault();
1273
+ event.stopPropagation();
1274
+ window.fpasoterm.killTerminal().then(() => {
1275
+ showDiagnostic('terminal kill requested');
1276
+ }).catch((error) => {
1277
+ showDiagnostic(`terminal kill failed: ${error}`);
1278
+ });
1279
+ return;
1280
+ }
1281
+
1282
+ const isCloseAllShortcut = matchesKeybinding(event, 'closeAll');
1283
+ if (isCloseAllShortcut) {
1284
+ event.preventDefault();
1285
+ event.stopPropagation();
1286
+ requestCloseAllWindows();
1287
+ return;
1288
+ }
1289
+
1290
+ const isCopyShortcut = matchesKeybinding(event, 'copy');
1291
+ if (isCopyShortcut && selectedClipboardText()) {
1292
+ event.preventDefault();
1293
+ copyTerminalSelection().catch((error) => {
1294
+ showDiagnostic(`terminal copy failed: ${error}`);
1295
+ });
1296
+ return;
1297
+ }
1298
+
1299
+ const isPasteShortcut = matchesKeybinding(event, 'paste');
1300
+ if (!isPasteShortcut) {
1301
+ return;
1302
+ }
1303
+ // The capture handler schedules a fallback. Do not cancel the native paste
1304
+ // action here or ChromeOS cannot provide event.clipboardData to the terminal.
1305
+ });
1306
+
1307
+ window.addEventListener('copy', (event) => {
1308
+ const text = selectedClipboardText();
1309
+ if (!text) {
1310
+ return;
1311
+ }
1312
+ event.preventDefault();
1313
+ event.clipboardData?.setData('text/plain', text);
1314
+ event.clipboardData?.setData('text', text);
1315
+ window.fpasoterm.writeClipboard(text).then(() => {
1316
+ showDiagnostic(`selection copied via copy event bytes=${text.length}`);
1317
+ }).catch((error) => {
1318
+ showDiagnostic(`copy event backend write failed: ${error}`);
1319
+ });
1320
+ });
1321
+ }
1322
+
1323
+ // Resolves OSC controls without accepting unsupported values from terminal output.
1324
+ function oscSecurityConfig() {
1325
+ return mergeConfig(fallbackConfig.security, appConfig.security || {});
1326
+ }
1327
+
1328
+ // Limits OSC 52 payloads before base64 decoding to avoid clipboard abuse.
1329
+ function osc52MaximumBytes() {
1330
+ const configured = Number(oscSecurityConfig().osc52MaxBytes);
1331
+ return Number.isFinite(configured) ? Math.max(0, Math.min(1048576, configured)) : 65536;
1332
+ }
1333
+
1334
+ // Decodes the base64 payload used by terminal OSC 52 clipboard commands.
1335
+ function decodeOsc52Text(encodedText) {
1336
+ const binary = atob(String(encodedText || ''));
1337
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
1338
+ return new TextDecoder().decode(bytes);
1339
+ }
1340
+
1341
+ // Applies terminal OSC 52 clipboard write requests from tools such as multiplexers.
1342
+ function applyOsc52Clipboard(selection, encodedText) {
1343
+ if (!encodedText) {
1344
+ return;
1345
+ }
1346
+ const security = oscSecurityConfig();
1347
+ if (security.osc52 !== 'trusted') {
1348
+ showDebugDiagnostic('ignored OSC 52 clipboard request because security.osc52 is disabled');
1349
+ return;
1350
+ }
1351
+ const maxBytes = osc52MaximumBytes();
1352
+ // Base64 takes at least four characters per three decoded bytes.
1353
+ if (String(encodedText).length > Math.ceil(maxBytes / 3) * 4 + 4) {
1354
+ showDiagnostic(`ignored OSC 52 clipboard request exceeding ${maxBytes} bytes`);
1355
+ return;
1356
+ }
1357
+ try {
1358
+ const text = decodeOsc52Text(encodedText);
1359
+ if (new TextEncoder().encode(text).length > maxBytes) {
1360
+ showDiagnostic(`ignored OSC 52 clipboard request exceeding ${maxBytes} bytes`);
1361
+ return;
1362
+ }
1363
+ window.fpasoterm.writeClipboard(text).then(() => {
1364
+ showDiagnostic(`OSC 52 clipboard wrote bytes=${text.length} selection=${selection || 'clipboard'}`);
1365
+ }).catch((error) => {
1366
+ showDiagnostic(`OSC 52 clipboard write failed: ${error}`);
1367
+ });
1368
+ } catch (error) {
1369
+ showDiagnostic(`OSC 52 clipboard decode failed: ${error}`);
1370
+ }
1371
+ }
1372
+
1373
+ // Records a shell-reported working directory without opening or inspecting it.
1374
+ function applyOsc7WorkingDirectory(value) {
1375
+ if (oscSecurityConfig().osc7 !== true) {
1376
+ return;
1377
+ }
1378
+ try {
1379
+ const uri = new URL(String(value || ''));
1380
+ if (uri.protocol !== 'file:') {
1381
+ return;
1382
+ }
1383
+ const path = decodeURIComponent(uri.pathname || '');
1384
+ if (!path || path.length > 4096) {
1385
+ return;
1386
+ }
1387
+ oscSessionMetadata.cwd = path;
1388
+ oscSessionMetadata.cwdHost = uri.hostname || '';
1389
+ showDebugDiagnostic(`OSC 7 cwd updated: ${oscSessionMetadata.cwd}`);
1390
+ } catch (_) {
1391
+ showDebugDiagnostic('ignored malformed OSC 7 working-directory report');
1392
+ }
1393
+ }
1394
+
1395
+ // Opens a separate terminal only after OSC 7 supplied a local working directory.
1396
+ function openWindowAtCurrentDirectory() {
1397
+ const cwd = String(oscSessionMetadata.cwd || '').trim();
1398
+ if (!cwd) {
1399
+ showTerminalError('New CWD is unavailable until the shell reports its current directory. Wait for the prompt, then try again.');
1400
+ showDiagnostic('new CWD window skipped: no local OSC 7 current directory was reported by the shell');
1401
+ return;
1402
+ }
1403
+ window.fpasoterm.newWindowAtCwd(cwd, oscSessionMetadata.cwdHost)
1404
+ .then((message) => showDiagnostic(message))
1405
+ .catch((error) => showDiagnostic(`new CWD window failed: ${error}`));
1406
+ }
1407
+
1408
+ // Tracks OSC 133 shell integration markers for diagnostics and future log views.
1409
+ function applyOsc133ShellIntegration(value) {
1410
+ if (oscSecurityConfig().osc133 !== true) {
1411
+ return;
1412
+ }
1413
+ const [marker, detail = ''] = String(value || '').split(';', 2);
1414
+ const normalized = marker.trim().toUpperCase();
1415
+ if (!['A', 'B', 'C', 'D', 'E', 'F', 'G'].includes(normalized)) {
1416
+ return;
1417
+ }
1418
+ oscSessionMetadata.lastMarker = normalized;
1419
+ if (normalized === 'C') {
1420
+ oscSessionMetadata.commandState = 'running';
1421
+ oscSessionMetadata.lastExitCode = '';
1422
+ } else if (normalized === 'D') {
1423
+ oscSessionMetadata.commandState = 'idle';
1424
+ oscSessionMetadata.lastExitCode = detail.trim();
1425
+ } else if (normalized === 'A') {
1426
+ oscSessionMetadata.commandState = 'prompt';
1427
+ }
1428
+ showDebugDiagnostic(`OSC 133 marker=${normalized}${detail ? ` detail=${detail}` : ''}`);
1429
+ }
1430
+
1431
+ // Limits OSC 9/99 notifications because terminal output is not a trusted UI channel.
1432
+ function oscNotificationMinimumInterval() {
1433
+ const configured = Number(oscSecurityConfig().oscNotificationMinIntervalMs);
1434
+ return Number.isFinite(configured) ? Math.max(1000, Math.min(60000, configured)) : 5000;
1435
+ }
1436
+
1437
+ function normalizedOscNotificationText(value) {
1438
+ return String(value || '')
1439
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
1440
+ .replace(/\s+/g, ' ')
1441
+ .trim()
1442
+ .slice(0, 512);
1443
+ }
1444
+
1445
+ // Sends an opt-in, rate-limited desktop notification for OSC 9/99 output.
1446
+ async function applyOscNotification(sequence, value) {
1447
+ if (oscSecurityConfig().oscNotifications !== true) {
1448
+ return;
1449
+ }
1450
+ const text = normalizedOscNotificationText(value);
1451
+ if (!text) {
1452
+ return;
1453
+ }
1454
+ const now = Date.now();
1455
+ if (now - lastOscNotificationAt < oscNotificationMinimumInterval()) {
1456
+ showDebugDiagnostic(`ignored OSC ${sequence} notification due to rate limit`);
1457
+ return;
1458
+ }
1459
+ const notification = window.__TAURI__?.notification;
1460
+ if (!notification?.isPermissionGranted || !notification?.requestPermission || !notification?.sendNotification) {
1461
+ showDiagnostic(`OSC ${sequence} notification is unavailable in this runtime`);
1462
+ return;
1463
+ }
1464
+ try {
1465
+ let granted = await notification.isPermissionGranted();
1466
+ if (!granted) {
1467
+ granted = (await notification.requestPermission()) === 'granted';
1468
+ }
1469
+ if (!granted) {
1470
+ showDiagnostic(`OSC ${sequence} notification permission was not granted`);
1471
+ return;
1472
+ }
1473
+ await notification.sendNotification({ title: 'fpasoterm', body: text });
1474
+ lastOscNotificationAt = now;
1475
+ oscSessionMetadata.lastNotification = new Date(now).toISOString();
1476
+ showDebugDiagnostic(`OSC ${sequence} notification delivered`);
1477
+ } catch (error) {
1478
+ showDiagnostic(`OSC ${sequence} notification failed: ${error}`);
1479
+ }
1480
+ }
1481
+
1482
+ // Deep-merges renderer fallback settings with main-process settings.
1483
+ function mergeConfig(base, override) {
1484
+ if (!override || typeof override !== 'object' || Array.isArray(override)) {
1485
+ return base;
1486
+ }
1487
+
1488
+ const merged = { ...base };
1489
+ for (const [key, value] of Object.entries(override)) {
1490
+ const baseValue = base[key];
1491
+ if (
1492
+ value &&
1493
+ typeof value === 'object' &&
1494
+ !Array.isArray(value) &&
1495
+ baseValue &&
1496
+ typeof baseValue === 'object' &&
1497
+ !Array.isArray(baseValue)
1498
+ ) {
1499
+ merged[key] = mergeConfig(baseValue, value);
1500
+ } else {
1501
+ merged[key] = value;
1502
+ }
1503
+ }
1504
+ return merged;
1505
+ }
1506
+
1507
+ // Fetches the resolved config and plugin URLs exposed by the backend API.
1508
+ async function loadRuntimeConfig() {
1509
+ try {
1510
+ const runtimeConfig = await window.fpasoterm.getConfig();
1511
+ appConfig = mergeConfig(fallbackConfig, runtimeConfig.config || {});
1512
+ activeConfigPath = String(runtimeConfig.configPath || '');
1513
+ debugKeys = debugKeys || runtimeConfig.diagnostics?.debugKeys || runtimeConfig.diagnostics?.consoleDiagnostics;
1514
+ pluginUrls = Array.isArray(runtimeConfig.pluginUrls) ? runtimeConfig.pluginUrls : [];
1515
+ applyKeybindingLabels();
1516
+ showDiagnostic(`renderer loaded config ${runtimeConfig.configPath}`);
1517
+ showDiagnostic(
1518
+ `renderer resolved config title=${appConfig.window?.title || ''} titlebarColor=${appConfig.window?.titlebarColor || ''} shell=${appConfig.terminal?.shell || ''}`,
1519
+ );
1520
+ } catch (error) {
1521
+ console.error(`renderer failed to load config: ${error.stack || error.message || error}`);
1522
+ appConfig = fallbackConfig;
1523
+ pluginUrls = [];
1524
+ }
1525
+ }
1526
+
1527
+ // Applies window-level visual settings that affect the renderer surface.
1528
+ function applyWindowAppearance() {
1529
+ const windowConfig = appConfig.window || {};
1530
+ document.documentElement.classList.toggle('frameless-window', windowConfig.frame === false);
1531
+ const background = windowConfig.backgroundColor || fallbackConfig.window.backgroundColor;
1532
+ const title = windowConfig.title || fallbackConfig.window.title;
1533
+ const titlebarColor = windowConfig.titlebarColor || fallbackConfig.window.titlebarColor;
1534
+ document.documentElement.style.background = background;
1535
+ document.body.style.background = background;
1536
+ document.title = title;
1537
+ if (windowTitleElement) {
1538
+ windowTitleElement.textContent = title;
1539
+ }
1540
+ document.documentElement.style.setProperty('--titlebar-background', titlebarColor);
1541
+ showDebugDiagnostic(`renderer applied window title=${title} titlebarColor=${titlebarColor}`);
1542
+ }
1543
+
1544
+ // Normalizes opacity values to the CSS alpha range.
1545
+ function normalizeOpacity(value) {
1546
+ const opacity = Number(value);
1547
+ if (!Number.isFinite(opacity)) {
1548
+ return undefined;
1549
+ }
1550
+ return Math.max(0, Math.min(1, opacity));
1551
+ }
1552
+
1553
+ // Returns a color with the requested alpha for common rgb/rgba/hex config values.
1554
+ function colorWithOpacity(color, opacity) {
1555
+ const alpha = normalizeOpacity(opacity);
1556
+ const source = String(color || '').trim();
1557
+ if (alpha === undefined || !source) {
1558
+ return source;
1559
+ }
1560
+
1561
+ const rgbMatch = source.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*[0-9.]+)?\s*\)$/i);
1562
+ if (rgbMatch) {
1563
+ return `rgba(${Number(rgbMatch[1])}, ${Number(rgbMatch[2])}, ${Number(rgbMatch[3])}, ${alpha})`;
1564
+ }
1565
+
1566
+ const shortHex = source.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
1567
+ if (shortHex) {
1568
+ const red = parseInt(`${shortHex[1]}${shortHex[1]}`, 16);
1569
+ const green = parseInt(`${shortHex[2]}${shortHex[2]}`, 16);
1570
+ const blue = parseInt(`${shortHex[3]}${shortHex[3]}`, 16);
1571
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
1572
+ }
1573
+
1574
+ const hex = source.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
1575
+ if (hex) {
1576
+ return `rgba(${parseInt(hex[1], 16)}, ${parseInt(hex[2], 16)}, ${parseInt(hex[3], 16)}, ${alpha})`;
1577
+ }
1578
+
1579
+ return source;
1580
+ }
1581
+
1582
+ // Builds a terminal theme whose background alpha follows terminal.backgroundOpacity.
1583
+ function terminalThemeWithOpacity(terminalConfig) {
1584
+ const theme = { ...(terminalConfig.theme || {}) };
1585
+ if (terminalConfig.backgroundOpacity !== undefined && theme.background) {
1586
+ theme.background = colorWithOpacity(theme.background, terminalConfig.backgroundOpacity);
1587
+ }
1588
+ return theme;
1589
+ }
1590
+
1591
+ // Applies runtime terminal options that xterm.js supports changing after open.
1592
+ function applyTerminalAppearance() {
1593
+ if (!term) {
1594
+ return;
1595
+ }
1596
+
1597
+ const terminalConfig = appConfig.terminal || {};
1598
+ for (const key of [
1599
+ 'cursorBlink',
1600
+ 'cursorStyle',
1601
+ 'fontFamily',
1602
+ 'fontSize',
1603
+ 'lineHeight',
1604
+ 'minimumContrastRatio',
1605
+ 'rescaleOverlappingGlyphs',
1606
+ 'scrollback',
1607
+ ]) {
1608
+ if (terminalConfig[key] !== undefined) {
1609
+ term.options[key] = terminalConfig[key];
1610
+ }
1611
+ }
1612
+ term.options.theme = terminalThemeWithOpacity(terminalConfig);
1613
+ scheduleDeferredFitAndResize();
1614
+ }
1615
+
1616
+ // Applies a freshly loaded config to the live window and terminal.
1617
+ async function applyRuntimeConfig(runtimeConfig) {
1618
+ appConfig = mergeConfig(fallbackConfig, runtimeConfig.config || {});
1619
+ activeConfigPath = String(runtimeConfig.configPath || activeConfigPath);
1620
+ applyKeybindingLabels();
1621
+ pluginUrls = Array.isArray(runtimeConfig.pluginUrls) ? runtimeConfig.pluginUrls : [];
1622
+ applyWindowAppearance();
1623
+ applyTerminalAppearance();
1624
+
1625
+ const width = Number(appConfig.window?.width);
1626
+ const height = Number(appConfig.window?.height);
1627
+ if (Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0) {
1628
+ await window.fpasoterm.setWindowBounds({ width, height });
1629
+ await afterNextPaint();
1630
+ fitAndResize();
1631
+ scheduleWindowStateSave();
1632
+ }
1633
+
1634
+ showDiagnostic(`runtime config applied ${runtimeConfig.configPath}`);
1635
+ }
1636
+
1637
+ // Loads and applies a config.toml path while the current terminal session keeps running.
1638
+ async function applyRuntimeConfigPath(configPath) {
1639
+ const normalizedPath = String(configPath || '').trim();
1640
+ if (!normalizedPath) {
1641
+ showDiagnostic('ignored empty runtime config path');
1642
+ return;
1643
+ }
1644
+
1645
+ try {
1646
+ const runtimeConfig = await window.fpasoterm.applyConfigPath(normalizedPath);
1647
+ await applyRuntimeConfig(runtimeConfig);
1648
+ } catch (error) {
1649
+ showDiagnostic(`runtime config apply failed path=${normalizedPath}: ${error}`);
1650
+ }
1651
+ }
1652
+
1653
+ // Updates both the browser document title and the visible custom titlebar text.
1654
+ function setRuntimeWindowTitle(title, options = {}) {
1655
+ const normalizedTitle = String(title || '').trim();
1656
+ if (!normalizedTitle) {
1657
+ return;
1658
+ }
1659
+ if (appConfig.window?.titleLocked && !options.force) {
1660
+ showDebugDiagnostic(`ignored shell title change while title is locked: ${normalizedTitle}`);
1661
+ return;
1662
+ }
1663
+
1664
+ document.title = normalizedTitle;
1665
+ if (windowTitleElement) {
1666
+ windowTitleElement.textContent = normalizedTitle;
1667
+ }
1668
+ }
1669
+
1670
+ // Applies a runtime titlebar color when the value is a valid CSS color.
1671
+ function setRuntimeTitlebarColor(color) {
1672
+ const normalizedColor = String(color || '').trim();
1673
+ if (!normalizedColor || !CSS.supports('color', normalizedColor)) {
1674
+ showDiagnostic(`ignored invalid titlebar color: ${normalizedColor}`);
1675
+ return;
1676
+ }
1677
+
1678
+ document.documentElement.style.setProperty('--titlebar-background', normalizedColor);
1679
+ }
1680
+
1681
+ // Applies fpasoterm-specific OSC 777 commands emitted by shell scripts.
1682
+ function applyFpasotermOsc(command) {
1683
+ const fields = String(command || '').split(';');
1684
+ const values = {};
1685
+ for (const field of fields) {
1686
+ const separator = field.indexOf('=');
1687
+ if (separator === -1) {
1688
+ continue;
1689
+ }
1690
+
1691
+ const key = field.slice(0, separator).trim();
1692
+ const value = field.slice(separator + 1).trim();
1693
+ values[key] = value;
1694
+ if (key === 'title') {
1695
+ setRuntimeWindowTitle(value, { force: true });
1696
+ } else if (key === 'titlebarColor') {
1697
+ setRuntimeTitlebarColor(value);
1698
+ } else if (key === 'opacity' || key === 'backgroundOpacity' || key === 'terminalOpacity') {
1699
+ const opacity = normalizeOpacity(value);
1700
+ if (opacity === undefined) {
1701
+ showDiagnostic(`ignored invalid terminal opacity: ${value}`);
1702
+ } else {
1703
+ appConfig = mergeConfig(appConfig, { terminal: { backgroundOpacity: opacity } });
1704
+ applyTerminalAppearance();
1705
+ }
1706
+ } else if (key === 'config' || key === 'configPath') {
1707
+ applyRuntimeConfigPath(value);
1708
+ }
1709
+ }
1710
+
1711
+ if (values.log === 'start') {
1712
+ startTerminalOutputLog(values.logPath || values.path || '').catch((error) => {
1713
+ showDiagnostic(`terminal log start failed: ${error}`);
1714
+ });
1715
+ } else if (values.log === 'stop') {
1716
+ stopTerminalOutputLog().catch((error) => {
1717
+ showDiagnostic(`terminal log stop failed: ${error}`);
1718
+ });
1719
+ }
1720
+ }
1721
+
1722
+ // Watches PTY output for runtime OSC commands before xterm draws it.
1723
+ function processRuntimeOsc(data) {
1724
+ pendingOscData = `${pendingOscData}${data}`;
1725
+ const oscPattern = /\x1b\](\d+);([\s\S]*?)(?:\x07|\x1b\\)/g;
1726
+ let match;
1727
+ let lastMatchEnd = 0;
1728
+ while ((match = oscPattern.exec(pendingOscData)) !== null) {
1729
+ if (match[1] === '777') {
1730
+ applyFpasotermOsc(match[2]);
1731
+ } else if (match[1] === '52') {
1732
+ const separator = match[2].indexOf(';');
1733
+ if (separator !== -1) {
1734
+ applyOsc52Clipboard(match[2].slice(0, separator), match[2].slice(separator + 1));
1735
+ }
1736
+ } else if (match[1] === '7') {
1737
+ applyOsc7WorkingDirectory(match[2]);
1738
+ } else if (match[1] === '133') {
1739
+ applyOsc133ShellIntegration(match[2]);
1740
+ } else if (match[1] === '9' || match[1] === '99') {
1741
+ applyOscNotification(match[1], match[2]);
1742
+ }
1743
+ lastMatchEnd = oscPattern.lastIndex;
1744
+ }
1745
+
1746
+ if (lastMatchEnd > 0) {
1747
+ pendingOscData = pendingOscData.slice(lastMatchEnd);
1748
+ }
1749
+ if (pendingOscData.length > 8192 || !/\x1b\](777|52|7|133|9|99);/.test(pendingOscData)) {
1750
+ pendingOscData = pendingOscData.slice(-64);
1751
+ }
1752
+ }
1753
+
1754
+ // Removes xterm.js visual-only overlay DOM that can appear as fixed garbled text on macOS.
1755
+ function removeXtermVisualOverlays() {
1756
+ if (!terminalElement) {
1757
+ return;
1758
+ }
1759
+
1760
+ for (const element of terminalElement.querySelectorAll('.xterm-accessibility, .xterm-message')) {
1761
+ element.remove();
1762
+ }
1763
+ }
1764
+
1765
+ // Emits canvas layout information when key/debug diagnostics are enabled.
1766
+ function logXtermCanvasDiagnostics() {
1767
+ if (!debugKeys || !terminalElement) {
1768
+ return;
1769
+ }
1770
+
1771
+ const canvases = [...terminalElement.querySelectorAll('canvas')].map((canvas, index) => {
1772
+ const rect = canvas.getBoundingClientRect();
1773
+ const style = window.getComputedStyle(canvas);
1774
+ return [
1775
+ `canvas[${index}]`,
1776
+ `class=${canvas.className || '(none)'}`,
1777
+ `width=${canvas.width}`,
1778
+ `height=${canvas.height}`,
1779
+ `rect=${Math.round(rect.width)}x${Math.round(rect.height)}@${Math.round(rect.left)},${Math.round(rect.top)}`,
1780
+ `display=${style.display}`,
1781
+ `visibility=${style.visibility}`,
1782
+ `opacity=${style.opacity}`,
1783
+ `z=${style.zIndex}`,
1784
+ ].join(' ');
1785
+ });
1786
+ showDebugDiagnostic(`xterm canvas diagnostics count=${canvases.length} ${canvases.join(' | ')}`);
1787
+ }
1788
+
1789
+ // Emits visible DOM nodes containing repeated W text when diagnostics are enabled.
1790
+ function logXtermTextDiagnostics() {
1791
+ if (!debugKeys || !terminalElement) {
1792
+ return;
1793
+ }
1794
+
1795
+ const matches = [];
1796
+ const walker = document.createTreeWalker(terminalElement, NodeFilter.SHOW_TEXT);
1797
+ while (matches.length < 12) {
1798
+ const node = walker.nextNode();
1799
+ if (!node) {
1800
+ break;
1801
+ }
1802
+ const text = node.textContent || '';
1803
+ if (!/W{3,}/.test(text)) {
1804
+ continue;
1805
+ }
1806
+ const parent = node.parentElement;
1807
+ const rect = parent?.getBoundingClientRect();
1808
+ const style = parent ? window.getComputedStyle(parent) : undefined;
1809
+ matches.push([
1810
+ `tag=${parent?.tagName || '(none)'}`,
1811
+ `class=${parent?.className || '(none)'}`,
1812
+ `text=${text.slice(0, 80)}`,
1813
+ `rect=${rect ? `${Math.round(rect.width)}x${Math.round(rect.height)}@${Math.round(rect.left)},${Math.round(rect.top)}` : '(none)'}`,
1814
+ `display=${style?.display || '(none)'}`,
1815
+ `visibility=${style?.visibility || '(none)'}`,
1816
+ `opacity=${style?.opacity || '(none)'}`,
1817
+ `z=${style?.zIndex || '(none)'}`,
1818
+ ].join(' '));
1819
+ }
1820
+ showDebugDiagnostic(`xterm text diagnostics repeated-w count=${matches.length} ${matches.join(' | ')}`);
1821
+ }
1822
+
1823
+ // Keeps xterm accessibility/message overlays out of the DOM if xterm recreates them.
1824
+ function installXtermOverlayPruner() {
1825
+ removeXtermVisualOverlays();
1826
+ if (!terminalElement || xtermOverlayObserver) {
1827
+ return;
1828
+ }
1829
+
1830
+ xtermOverlayObserver = new MutationObserver(() => {
1831
+ removeXtermVisualOverlays();
1832
+ });
1833
+ xtermOverlayObserver.observe(terminalElement, {
1834
+ childList: true,
1835
+ subtree: true,
1836
+ });
1837
+ }
1838
+
1839
+ // Creates xterm.js using the resolved terminal settings.
1840
+ function createTerminal() {
1841
+ if (!terminalElement) {
1842
+ return;
1843
+ }
1844
+
1845
+ const terminalOptions = { ...appConfig.terminal };
1846
+ delete terminalOptions.kittyKeyboard;
1847
+
1848
+ term = new Terminal({
1849
+ ...terminalOptions,
1850
+ theme: terminalThemeWithOpacity(appConfig.terminal || {}),
1851
+ screenReaderMode: false,
1852
+ // Keep this opt-in until it is verified not to interfere with IME input.
1853
+ vtExtensions: { kittyKeyboard: appConfig.terminal?.kittyKeyboard === true },
1854
+ linkHandler: {
1855
+ activate: (_, uri) => showTerminalUrlDialog(uri, 'OSC 8 link'),
1856
+ },
1857
+ });
1858
+ if (typeof term.onTitleChange === 'function') {
1859
+ term.onTitleChange((title) => setRuntimeWindowTitle(title));
1860
+ }
1861
+ fitAddon = new FitAddon.FitAddon();
1862
+ term.loadAddon(fitAddon);
1863
+ const imageConfig = appConfig.terminal?.images || {};
1864
+ // ImageAddon can stall the current Tauri/WebKitGTK WebView on ChromeOS.
1865
+ // Keep the requested settings for future diagnostics, but do not load it.
1866
+ const graphicsEnabled = false;
1867
+ if (imageConfig.enabled === true) {
1868
+ showDiagnostic('terminal graphics are disabled in this build because ImageAddon can block input');
1869
+ }
1870
+ if (graphicsEnabled && window.ImageAddon?.ImageAddon) {
1871
+ imageAddon = new ImageAddon.ImageAddon({
1872
+ kittySupport: imageConfig.kittySupport === true,
1873
+ kittySizeLimit: Number(imageConfig.kittySizeLimit) || 32 * 1024 * 1024,
1874
+ storageLimit: Number(imageConfig.storageLimit) || 64,
1875
+ sixelSupport: imageConfig.sixelSupport === true,
1876
+ iipSupport: imageConfig.iipSupport === true,
1877
+ });
1878
+ term.loadAddon(imageAddon);
1879
+ // Image layers are added asynchronously. Keep IME and keyboard input on xterm.
1880
+ if (typeof imageAddon.onImageAdded === 'function') {
1881
+ imageAddon.onImageAdded(() => requestAnimationFrame(focusTerminalInput));
1882
+ }
1883
+ } else if (graphicsEnabled) {
1884
+ showDiagnostic('xterm image addon is unavailable; terminal graphics are disabled');
1885
+ }
1886
+ term.open(terminalElement);
1887
+ installTerminalLinkHandlers();
1888
+ installXtermOverlayPruner();
1889
+ logXtermCanvasDiagnostics();
1890
+ logXtermTextDiagnostics();
1891
+ showDebugDiagnostic(`terminal opened cols=${term.cols} rows=${term.rows}`);
1892
+ }
1893
+
1894
+ // Converts a trusted file URL from the config resolver into Tauri's scoped
1895
+ // asset protocol URL. This keeps local plugins compatible with the WebView CSP.
1896
+ function pluginScriptSource(plugin) {
1897
+ const fileUrl = String(plugin?.url || '');
1898
+ const convertFileSrc = window.__TAURI__?.core?.convertFileSrc;
1899
+ if (!fileUrl.startsWith('file:') || typeof convertFileSrc !== 'function') {
1900
+ return fileUrl;
1901
+ }
1902
+ try {
1903
+ let filePath = decodeURIComponent(new URL(fileUrl).pathname);
1904
+ if (/^\/[A-Za-z]:\//.test(filePath)) {
1905
+ filePath = filePath.slice(1);
1906
+ }
1907
+ return convertFileSrc(filePath);
1908
+ } catch (error) {
1909
+ showDiagnostic(`plugin URL conversion failed for ${plugin?.name || 'unknown'}: ${error?.message || error}`);
1910
+ return fileUrl;
1911
+ }
1912
+ }
1913
+
1914
+ // Publishes the plugin API and loads enabled user plugins in order.
1915
+ async function loadPlugins() {
1916
+ window.fpasotermPluginApi = Object.freeze({
1917
+ version: pluginVersion,
1918
+ terminal: term,
1919
+ fitAddon,
1920
+ imageAddon,
1921
+ config: appConfig,
1922
+ log: (message) => showDiagnostic(`plugin: ${message}`),
1923
+ getOfficialPluginIndex: () => window.fpasoterm.getPluginCatalog(),
1924
+ onReady: registerPluginReadyCallback,
1925
+ registerCommand: registerPluginCommand,
1926
+ });
1927
+
1928
+ for (const plugin of pluginUrls) {
1929
+ await new Promise((resolve) => {
1930
+ const commandCountBeforeLoad = pluginCommands.size;
1931
+ const script = document.createElement('script');
1932
+ const source = pluginScriptSource(plugin);
1933
+ script.src = source;
1934
+ script.async = false;
1935
+ script.onload = () => {
1936
+ const registeredCommandCount = pluginCommands.size - commandCountBeforeLoad;
1937
+ showDiagnostic(`plugin loaded ${plugin.name} commands=${registeredCommandCount} source=${source}`);
1938
+ resolve();
1939
+ };
1940
+ script.onerror = () => {
1941
+ const message = `failed to load plugin ${plugin.name} source=${source}`;
1942
+ console.error(message);
1943
+ showDiagnostic(message);
1944
+ resolve();
1945
+ };
1946
+ document.head.appendChild(script);
1947
+ });
1948
+ }
1949
+ updatePluginMenuVisibility();
1950
+ }
1951
+
1952
+ // Shows Plugins only when at least one enabled plugin registered a command.
1953
+ // Loading a plugin alone does not create a menu action because it has no handler.
1954
+ function updatePluginMenuVisibility() {
1955
+ if (!pluginMenuSection || !pluginCommandItems) {
1956
+ return;
1957
+ }
1958
+ const hasCommands = pluginCommands.size > 0;
1959
+ pluginMenuSection.hidden = !hasCommands;
1960
+ if (!hasCommands) {
1961
+ pluginCommandItems.hidden = true;
1962
+ pluginMenuToggleButton?.setAttribute('aria-expanded', 'false');
1963
+ }
1964
+ }
1965
+
1966
+ // Registers work that needs a successfully started PTY instead of only xterm.
1967
+ function registerPluginReadyCallback(callback) {
1968
+ if (typeof callback !== 'function') {
1969
+ throw new TypeError('plugin onReady callback must be a function');
1970
+ }
1971
+ if (pluginsReady) {
1972
+ queueMicrotask(() => runPluginReadyCallback(callback));
1973
+ return;
1974
+ }
1975
+ pluginReadyCallbacks.push(callback);
1976
+ }
1977
+
1978
+ // Runs one plugin callback without allowing a plugin error to stop the renderer.
1979
+ function runPluginReadyCallback(callback) {
1980
+ Promise.resolve()
1981
+ .then(callback)
1982
+ .catch((error) => showDiagnostic(`plugin onReady failed: ${error?.stack || error}`));
1983
+ }
1984
+
1985
+ // Schedules plugin startup after terminal output has become idle and painted.
1986
+ // Shell initialization can clear the terminal after startTerminal() resolves,
1987
+ // so notifying plugins directly at that point can erase their first writeln().
1988
+ function schedulePluginsReadyAfterTerminalOutput(delayMs = 180) {
1989
+ if (pluginsReady) {
1990
+ return;
1991
+ }
1992
+ const generation = ++pluginReadyGeneration;
1993
+ if (pluginReadyTimer) {
1994
+ clearTimeout(pluginReadyTimer);
1995
+ }
1996
+ pluginReadyTimer = setTimeout(() => {
1997
+ pluginReadyTimer = null;
1998
+ afterNextPaint().then(() => {
1999
+ if (generation === pluginReadyGeneration) {
2000
+ notifyPluginsReady();
2001
+ }
2002
+ });
2003
+ }, delayMs);
2004
+ }
2005
+
2006
+ // Delivers the terminal-ready lifecycle event once after initial PTY output settles.
2007
+ function notifyPluginsReady() {
2008
+ if (pluginsReady) {
2009
+ return;
2010
+ }
2011
+ pluginsReady = true;
2012
+ for (const callback of pluginReadyCallbacks.splice(0)) {
2013
+ runPluginReadyCallback(callback);
2014
+ }
2015
+ }
2016
+
2017
+ // Adds a trusted plugin action to the existing keyboard-accessible window menu.
2018
+ function registerPluginCommand(id, title, handler) {
2019
+ const commandId = String(id || '').trim();
2020
+ const commandTitle = String(title || '').trim();
2021
+ if (!/^[A-Za-z][A-Za-z0-9._:-]{0,79}$/.test(commandId)) {
2022
+ throw new Error('plugin command id must start with a letter and use letters, numbers, ., _, :, or -');
2023
+ }
2024
+ if (!commandTitle || commandTitle.length > 80) {
2025
+ throw new Error('plugin command title must contain 1 to 80 characters');
2026
+ }
2027
+ if (typeof handler !== 'function') {
2028
+ throw new TypeError('plugin command handler must be a function');
2029
+ }
2030
+ if (pluginCommands.has(commandId)) {
2031
+ throw new Error(`plugin command is already registered: ${commandId}`);
2032
+ }
2033
+ if (!pluginMenuSection || !pluginCommandItems) {
2034
+ throw new Error('plugin command menu is unavailable');
2035
+ }
2036
+
2037
+ const button = document.createElement('button');
2038
+ button.type = 'button';
2039
+ button.role = 'menuitem';
2040
+ button.textContent = commandTitle;
2041
+ button.dataset.pluginCommand = commandId;
2042
+ button.addEventListener('click', () => runPluginCommand(commandId));
2043
+ pluginCommands.set(commandId, { handler, button });
2044
+ pluginCommandItems.appendChild(button);
2045
+ updatePluginMenuVisibility();
2046
+ showDiagnostic(`plugin command registered id=${commandId}`);
2047
+ }
2048
+
2049
+ // Invokes a registered command and reports plugin failures without closing the app.
2050
+ async function runPluginCommand(commandId) {
2051
+ const command = pluginCommands.get(commandId);
2052
+ if (!command) {
2053
+ return;
2054
+ }
2055
+ setWindowMenuOpen(false);
2056
+ try {
2057
+ await command.handler();
2058
+ } catch (error) {
2059
+ showDiagnostic(`plugin command ${commandId} failed: ${error?.stack || error}`);
2060
+ }
2061
+ }
2062
+
2063
+ // Returns true when sync-folder features are enabled in the resolved config.
2064
+ function syncEnabled() {
2065
+ const sync = appConfig.sync || {};
2066
+ return sync.enabled === true && sync.provider === 'folder' && Boolean(String(sync.path || '').trim());
2067
+ }
2068
+
2069
+ // Remote Broadcast needs both an explicit opt-in and a secret shared by trusted devices.
2070
+ function syncCommandsEnabled() {
2071
+ const sync = appConfig.sync || {};
2072
+ return syncEnabled() && sync.commands === true && String(sync.commandSecret || '').length >= 32;
2073
+ }
2074
+
2075
+ // Publishes the backend diagnostics ring buffer without creating a feedback loop.
2076
+ async function writeDiagnosticsSnapshot() {
2077
+ if (!syncDiagnosticsEnabled || !window.fpasoterm?.syncWriteDiagnostics) {
2078
+ return;
2079
+ }
2080
+ const item = await window.fpasoterm.syncWriteDiagnostics();
2081
+ console.error(`sync diagnostics auto-wrote bytes=${item.text.length} channel=${item.channel}`);
2082
+ }
2083
+
2084
+ // Debounces diagnostics writes so sync folders are not updated on every line.
2085
+ function scheduleSyncDiagnosticsWrite(delayMs = 1200) {
2086
+ if (!syncDiagnosticsEnabled) {
2087
+ return;
2088
+ }
2089
+ if (syncDiagnosticsTimer) {
2090
+ clearTimeout(syncDiagnosticsTimer);
2091
+ }
2092
+ syncDiagnosticsTimer = setTimeout(() => {
2093
+ writeDiagnosticsSnapshot().catch((error) => {
2094
+ console.error(`sync diagnostics auto-write failed: ${error}`);
2095
+ });
2096
+ }, delayMs);
2097
+ }
2098
+
2099
+ // Enables automatic diagnostics sync only when [sync] is configured.
2100
+ async function installSyncControls() {
2101
+ syncDiagnosticsEnabled = false;
2102
+ if (!syncEnabled()) {
2103
+ if (syncCleanButton) {
2104
+ syncCleanButton.disabled = true;
2105
+ }
2106
+ return;
2107
+ }
2108
+
2109
+ const status = await window.fpasoterm.syncStatus();
2110
+ if (syncCleanButton) {
2111
+ syncCleanButton.disabled = !status.enabled;
2112
+ }
2113
+ if (!status.enabled) {
2114
+ showDiagnostic(`sync disabled: ${status.message}`);
2115
+ return;
2116
+ }
2117
+
2118
+ syncDiagnosticsEnabled = true;
2119
+ showDiagnostic(`sync folder enabled channel=${status.channel} path=${status.path}`);
2120
+ scheduleSyncDiagnosticsWrite(0);
2121
+ }
2122
+
2123
+ // Updates the terminal output log button label from backend state.
2124
+ async function refreshTerminalLogControl() {
2125
+ if (!terminalLogToggleButton || !terminalLogStatusElement) {
2126
+ return;
2127
+ }
2128
+ const status = await window.fpasoterm.terminalLogStatus();
2129
+ terminalLogToggleButton.hidden = status.enabled === false;
2130
+ terminalLogShowButton.hidden = status.enabled === false;
2131
+ terminalLogToggleButton.textContent = `${status.active ? 'Log Stop' : 'Log Start'} (${keybindingActionLabel('logToggle')})`;
2132
+ terminalLogToggleButton.dataset.active = status.active ? 'true' : 'false';
2133
+ terminalLogStatusElement.hidden = !status.active;
2134
+ }
2135
+
2136
+ // Returns nested menu sections with their disclosure control and child actions.
2137
+ function windowMenuSubmenus() {
2138
+ return [
2139
+ { name: 'log', toggle: logMenuToggleButton, items: logMenuItems },
2140
+ { name: 'sync', toggle: syncMenuToggleButton, items: syncMenuItems },
2141
+ { name: 'diagnostics', toggle: diagnosticsMenuToggleButton, items: diagnosticsMenuItems },
2142
+ { name: 'plugins', toggle: pluginMenuToggleButton, items: pluginCommandItems },
2143
+ { name: 'window', toggle: windowActionsMenuToggleButton, items: windowActionsMenuItems },
2144
+ ].filter((submenu) => submenu.toggle && submenu.items);
2145
+ }
2146
+
2147
+ // Expands one menu section and optionally focuses a visible action inside it.
2148
+ function setWindowMenuSubmenuOpen(name, open, focusTarget) {
2149
+ const submenu = windowMenuSubmenus().find((candidate) => candidate.name === name);
2150
+ if (!submenu) {
2151
+ return;
2152
+ }
2153
+ submenu.items.hidden = !open;
2154
+ submenu.toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
2155
+ fitWindowMenuToViewport();
2156
+ if (open && focusTarget && !focusTarget.hidden && !focusTarget.disabled) {
2157
+ focusTarget.focus({ preventScroll: true });
2158
+ }
2159
+ }
2160
+
2161
+ // Collapses sections whenever the parent popup is closed or reopened.
2162
+ function closeWindowMenuSubmenus() {
2163
+ for (const submenu of windowMenuSubmenus()) {
2164
+ submenu.items.hidden = true;
2165
+ submenu.toggle.setAttribute('aria-expanded', 'false');
2166
+ }
2167
+ }
2168
+
2169
+ // Finds the nested section containing a menu item, if any.
2170
+ function windowMenuSubmenuForElement(element) {
2171
+ const container = element?.closest?.('[data-menu-submenu]');
2172
+ if (!container) {
2173
+ return undefined;
2174
+ }
2175
+ return windowMenuSubmenus().find((submenu) => container.dataset.menuSubmenu === submenu.name);
2176
+ }
2177
+
2178
+ // Moves keyboard focus inside the compact window menu.
2179
+ function focusWindowMenuItem(delta) {
2180
+ if (!windowMenuItems || windowMenuItems.hidden) {
2181
+ return;
2182
+ }
2183
+ const items = Array.from(windowMenuItems.querySelectorAll('button:not([hidden]):not(:disabled)'))
2184
+ .filter((item) => item.getClientRects().length > 0);
2185
+ if (items.length === 0) {
2186
+ return;
2187
+ }
2188
+ const currentIndex = items.indexOf(document.activeElement);
2189
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + delta + items.length) % items.length;
2190
+ items[nextIndex].focus();
2191
+ }
2192
+
2193
+ // Keeps the popup within the actual WebView viewport. CSS viewport units alone
2194
+ // can be stale briefly after a native resize on WebKitGTK.
2195
+ function fitWindowMenuToViewport() {
2196
+ if (!windowMenuItems || windowMenuItems.hidden) {
2197
+ return;
2198
+ }
2199
+ const top = windowMenuItems.getBoundingClientRect().top;
2200
+ const maximumHeight = Math.max(48, Math.floor(window.innerHeight - top - 8));
2201
+ windowMenuItems.style.maxHeight = `${maximumHeight}px`;
2202
+ }
2203
+
2204
+ // Lets a panel move within the current window without changing its saved bounds.
2205
+ function enableFloatingPanelDrag(panel, handle) {
2206
+ if (!panel || !handle) {
2207
+ return;
2208
+ }
2209
+ handle.addEventListener('pointerdown', (event) => {
2210
+ if (event.button !== 0 || event.target.closest('button, input, select, textarea, label')) {
2211
+ return;
2212
+ }
2213
+ const bounds = panel.getBoundingClientRect();
2214
+ const offsetX = event.clientX - bounds.left;
2215
+ const offsetY = event.clientY - bounds.top;
2216
+ panel.style.right = 'auto';
2217
+ panel.style.bottom = 'auto';
2218
+ panel.style.left = `${Math.max(0, bounds.left)}px`;
2219
+ panel.style.top = `${Math.max(0, bounds.top)}px`;
2220
+ const move = (moveEvent) => {
2221
+ const current = panel.getBoundingClientRect();
2222
+ const left = Math.max(0, Math.min(window.innerWidth - current.width, moveEvent.clientX - offsetX));
2223
+ const top = Math.max(0, Math.min(window.innerHeight - current.height, moveEvent.clientY - offsetY));
2224
+ panel.style.left = `${Math.round(left)}px`;
2225
+ panel.style.top = `${Math.round(top)}px`;
2226
+ };
2227
+ const stop = () => {
2228
+ window.removeEventListener('pointermove', move);
2229
+ window.removeEventListener('pointerup', stop);
2230
+ window.removeEventListener('pointercancel', stop);
2231
+ };
2232
+ window.addEventListener('pointermove', move);
2233
+ window.addEventListener('pointerup', stop, { once: true });
2234
+ window.addEventListener('pointercancel', stop, { once: true });
2235
+ event.preventDefault();
2236
+ });
2237
+ }
2238
+
2239
+ enableFloatingPanelDrag(diagnosticsPanel, diagnosticsPanel?.querySelector('[data-panel-drag-handle]'));
2240
+ enableFloatingPanelDrag(terminalBroadcastDialog, terminalBroadcastTitle);
2241
+
2242
+ // Returns focusable controls in the visible Broadcast dialog, including its
2243
+ // dynamically rendered local-window target checkboxes.
2244
+ function terminalBroadcastFocusItems() {
2245
+ if (!terminalBroadcastDialog || terminalBroadcastDialog.hidden) {
2246
+ return [];
2247
+ }
2248
+ if (terminalBroadcastConfirmElement && !terminalBroadcastConfirmElement.hidden) {
2249
+ return [
2250
+ terminalBroadcastConfirmOkButton,
2251
+ terminalBroadcastConfirmCancelButton,
2252
+ ].filter((element) => element && !element.hidden && !element.disabled);
2253
+ }
2254
+ const targets = [...terminalBroadcastTargetList.querySelectorAll('input[type="checkbox"]')];
2255
+ return [
2256
+ terminalBroadcastText,
2257
+ terminalBroadcastControl,
2258
+ terminalBroadcastControlInsertButton,
2259
+ ...targets,
2260
+ terminalBroadcastSelectAllButton,
2261
+ terminalBroadcastSelectNoneButton,
2262
+ terminalBroadcastSyncLabel?.hidden ? null : terminalBroadcastSync,
2263
+ terminalBroadcastSendButton,
2264
+ terminalBroadcastCancelButton,
2265
+ ].filter((element) => element && !element.hidden && !element.disabled && element.getClientRects().length > 0);
2266
+ }
2267
+
2268
+ // Describes the active Broadcast control so keyboard navigation remains visible.
2269
+ function terminalBroadcastFocusLabel(element) {
2270
+ if (element === terminalBroadcastText) {
2271
+ return 'Input';
2272
+ }
2273
+ if (element === terminalBroadcastControl) {
2274
+ return 'Control byte';
2275
+ }
2276
+ if (element === terminalBroadcastControlInsertButton) {
2277
+ return 'Insert control byte';
2278
+ }
2279
+ if (element === terminalBroadcastSync) {
2280
+ return 'Include synced channel';
2281
+ }
2282
+ if (element?.matches?.('#terminal-broadcast-target-list input[type="checkbox"]')) {
2283
+ return element.closest('label')?.textContent?.trim() || 'Local window';
2284
+ }
2285
+ return element?.getAttribute?.('aria-label') || element?.textContent?.trim() || 'Control';
2286
+ }
2287
+
2288
+ // Updates the visible focus indicator after a keyboard or pointer focus change.
2289
+ function updateTerminalBroadcastFocusStatus(element) {
2290
+ terminalBroadcastDialog?.querySelectorAll('.keyboard-focus').forEach((focusElement) => {
2291
+ focusElement.classList.remove('keyboard-focus');
2292
+ });
2293
+ element?.classList?.add('keyboard-focus');
2294
+ if (element?.matches?.('#terminal-broadcast-target-list input[type="checkbox"]')) {
2295
+ element.closest('label')?.classList.add('keyboard-focus');
2296
+ }
2297
+ element?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
2298
+ if (terminalBroadcastFocusStatus) {
2299
+ terminalBroadcastFocusStatus.textContent = `Keyboard focus: ${terminalBroadcastFocusLabel(element)}`;
2300
+ }
2301
+ }
2302
+
2303
+ // Cycles focus within Broadcast so Tab never falls through to xterm.js.
2304
+ function focusTerminalBroadcastItem(delta) {
2305
+ const items = terminalBroadcastFocusItems();
2306
+ if (items.length === 0) {
2307
+ return;
2308
+ }
2309
+ const currentIndex = items.indexOf(document.activeElement);
2310
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + delta + items.length) % items.length;
2311
+ items[nextIndex].focus({ preventScroll: true });
2312
+ updateTerminalBroadcastFocusStatus(items[nextIndex]);
2313
+ }
2314
+
2315
+ // Returns focusable controls in the visible diagnostics/log panel.
2316
+ function diagnosticsPanelFocusItems() {
2317
+ if (!diagnosticsPanel || diagnosticsPanel.hidden) {
2318
+ return [];
2319
+ }
2320
+ if (terminalLogConfirmElement && !terminalLogConfirmElement.hidden) {
2321
+ return [
2322
+ terminalLogConfirmOkButton,
2323
+ terminalLogConfirmCancelButton,
2324
+ ].filter((element) => element && !element.hidden && !element.disabled);
2325
+ }
2326
+ return [
2327
+ terminalLogSelectElement,
2328
+ terminalLogSearchElement,
2329
+ terminalLogSearchNextButton,
2330
+ terminalLogShowSelectedButton,
2331
+ terminalLogDeleteSelectedButton,
2332
+ terminalLogDeleteAllButton,
2333
+ checkForUpdatesButton,
2334
+ terminalEncodingSelectElement,
2335
+ terminalEncodingApplyButton,
2336
+ closeDiagnosticsButton,
2337
+ diagnosticsElement,
2338
+ fontGlyphPreviewElement,
2339
+ terminalCapabilityPreviewElement,
2340
+ ].filter((element) => element && !element.hidden && !element.disabled);
2341
+ }
2342
+
2343
+ // Moves focus inside the diagnostics/log panel without returning it to xterm.
2344
+ function focusDiagnosticsPanelItem(delta) {
2345
+ const items = diagnosticsPanelFocusItems();
2346
+ if (items.length === 0) {
2347
+ return;
2348
+ }
2349
+ const currentIndex = items.indexOf(document.activeElement);
2350
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + delta + items.length) % items.length;
2351
+ items[nextIndex].focus({ preventScroll: true });
2352
+ }
2353
+
2354
+ // Focuses the first useful log panel control after opening or refreshing logs.
2355
+ function focusTerminalLogPanel() {
2356
+ if (!diagnosticsPanel || diagnosticsPanel.hidden) {
2357
+ return;
2358
+ }
2359
+ const preferred = terminalLogSelectElement && !terminalLogSelectElement.hidden
2360
+ ? terminalLogSelectElement
2361
+ : closeDiagnosticsButton;
2362
+ preferred?.focus({ preventScroll: true });
2363
+ }
2364
+
2365
+ // Selects the next or previous occurrence of the search text in the visible log textarea.
2366
+ function scrollDiagnosticsSelectionIntoView(index) {
2367
+ const textBeforeMatch = diagnosticsElement.value.slice(0, index);
2368
+ const lineNumber = textBeforeMatch.split('\n').length - 1;
2369
+ const computedStyle = window.getComputedStyle(diagnosticsElement);
2370
+ const fontSize = Number.parseFloat(computedStyle.fontSize) || 12;
2371
+ const lineHeight = Number.parseFloat(computedStyle.lineHeight) || fontSize * 1.2;
2372
+ const targetTop = Math.max(0, lineNumber * lineHeight - diagnosticsElement.clientHeight / 2);
2373
+ diagnosticsElement.scrollTop = targetTop;
2374
+ }
2375
+
2376
+ function searchTerminalLogText(direction = 1) {
2377
+ const query = String(terminalLogSearchElement?.value || '');
2378
+ if (!query) {
2379
+ terminalLogSearchState = { query: '', text: '', matches: [], cursor: -1 };
2380
+ if (terminalLogSearchStatusElement) {
2381
+ terminalLogSearchStatusElement.textContent = '';
2382
+ }
2383
+ terminalLogSearchElement?.focus({ preventScroll: true });
2384
+ return false;
2385
+ }
2386
+
2387
+ const text = diagnosticsElement.value || '';
2388
+ const normalizedQuery = query.toLowerCase();
2389
+ const normalizedText = text.toLowerCase();
2390
+ const sameSearch =
2391
+ terminalLogSearchState.query === normalizedQuery &&
2392
+ terminalLogSearchState.text === normalizedText;
2393
+
2394
+ if (!sameSearch) {
2395
+ const matches = [];
2396
+ let start = 0;
2397
+ while (start <= normalizedText.length) {
2398
+ const index = normalizedText.indexOf(normalizedQuery, start);
2399
+ if (index === -1) {
2400
+ break;
2401
+ }
2402
+ matches.push(index);
2403
+ start = index + Math.max(1, normalizedQuery.length);
2404
+ }
2405
+ terminalLogSearchState = {
2406
+ query: normalizedQuery,
2407
+ text: normalizedText,
2408
+ matches,
2409
+ cursor: -1,
2410
+ };
2411
+ }
2412
+
2413
+ if (terminalLogSearchState.matches.length === 0) {
2414
+ terminalLogSearchState.cursor = -1;
2415
+ if (terminalLogSearchStatusElement) {
2416
+ terminalLogSearchStatusElement.textContent = '0/0';
2417
+ }
2418
+ showDiagnostic(`terminal log search not found: ${query}`);
2419
+ terminalLogSearchElement?.focus({ preventScroll: true });
2420
+ return false;
2421
+ }
2422
+
2423
+ const delta = direction < 0 ? -1 : 1;
2424
+ terminalLogSearchState.cursor =
2425
+ (terminalLogSearchState.cursor + delta + terminalLogSearchState.matches.length) %
2426
+ terminalLogSearchState.matches.length;
2427
+ const index = terminalLogSearchState.matches[terminalLogSearchState.cursor];
2428
+ diagnosticsElement.focus({ preventScroll: true });
2429
+ diagnosticsElement.setSelectionRange(index, index + query.length);
2430
+ scrollDiagnosticsSelectionIntoView(index);
2431
+ if (terminalLogSearchStatusElement) {
2432
+ terminalLogSearchStatusElement.textContent = `${terminalLogSearchState.cursor + 1}/${terminalLogSearchState.matches.length}`;
2433
+ }
2434
+ showDiagnostic(`terminal log search matched: ${query} ${terminalLogSearchState.cursor + 1}/${terminalLogSearchState.matches.length}`);
2435
+ return true;
2436
+ }
2437
+
2438
+ // Restores focus to a log-panel control after native confirmation dialogs close.
2439
+ function restoreLogPanelFocus(element, options = {}) {
2440
+ if (!diagnosticsPanel || diagnosticsPanel.hidden) {
2441
+ return;
2442
+ }
2443
+ const fallback = terminalLogSelectElement?.options?.length > 0 ? terminalLogSelectElement : closeDiagnosticsButton;
2444
+ const target = element && !element.hidden && !element.disabled ? element : fallback;
2445
+ const focusTarget = () => {
2446
+ if (diagnosticsPanel.hidden) {
2447
+ return;
2448
+ }
2449
+ diagnosticsPanel.focus({ preventScroll: true });
2450
+ target?.focus({ preventScroll: true });
2451
+ };
2452
+
2453
+ focusTarget();
2454
+
2455
+ if (!options.repeat) {
2456
+ return;
2457
+ }
2458
+
2459
+ afterNextPaint().then(() => {
2460
+ focusTarget();
2461
+ });
2462
+ for (const delay of [0, 50, 120, 240]) {
2463
+ setTimeout(focusTarget, delay);
2464
+ }
2465
+ }
2466
+
2467
+ // Closes the in-panel confirmation dialog and resolves the pending action.
2468
+ function resolveTerminalLogConfirm(confirmed) {
2469
+ if (!terminalLogConfirmElement || terminalLogConfirmElement.hidden) {
2470
+ return;
2471
+ }
2472
+ terminalLogConfirmElement.hidden = true;
2473
+ const resolver = terminalLogConfirmResolver;
2474
+ const returnFocus = terminalLogConfirmReturnFocus;
2475
+ terminalLogConfirmResolver = null;
2476
+ terminalLogConfirmReturnFocus = null;
2477
+ restoreLogPanelFocus(returnFocus, { repeat: true });
2478
+ resolver?.(confirmed);
2479
+ }
2480
+
2481
+ // Shows an in-panel confirmation dialog so focus stays within the log panel.
2482
+ function confirmTerminalLogAction(message, returnFocus) {
2483
+ if (!terminalLogConfirmElement || !terminalLogConfirmMessageElement || !terminalLogConfirmOkButton) {
2484
+ showDiagnostic('terminal log confirmation UI is unavailable');
2485
+ return Promise.resolve(false);
2486
+ }
2487
+
2488
+ if (terminalLogConfirmResolver) {
2489
+ resolveTerminalLogConfirm(false);
2490
+ }
2491
+
2492
+ terminalLogConfirmMessageElement.textContent = message;
2493
+ terminalLogConfirmReturnFocus = returnFocus;
2494
+ terminalLogConfirmElement.hidden = false;
2495
+ terminalLogConfirmOkButton.focus({ preventScroll: true });
2496
+ return new Promise((resolve) => {
2497
+ terminalLogConfirmResolver = resolve;
2498
+ });
2499
+ }
2500
+
2501
+ // Shows a separate confirmation dialog before broadcasting a close-all request.
2502
+ function confirmCloseAllWindows() {
2503
+ if (!closeAllConfirmElement || !closeAllConfirmMessageElement || !closeAllConfirmOkButton) {
2504
+ showDiagnostic('close all confirmation UI is unavailable');
2505
+ return Promise.resolve(false);
2506
+ }
2507
+ closeAllConfirmMessageElement.textContent =
2508
+ 'Close all running fpasoterm windows?\n\nThis will terminate every fpasoterm window.';
2509
+ closeAllConfirmElement.hidden = false;
2510
+ closeAllConfirmOkButton.focus({ preventScroll: true });
2511
+ return new Promise((resolve) => {
2512
+ closeAllConfirmResolver = resolve;
2513
+ });
2514
+ }
2515
+
2516
+ // Resolves and hides the close-all confirmation dialog.
2517
+ function resolveCloseAllWindows(confirmed) {
2518
+ if (!closeAllConfirmElement || closeAllConfirmElement.hidden) {
2519
+ return;
2520
+ }
2521
+ closeAllConfirmElement.hidden = true;
2522
+ const resolver = closeAllConfirmResolver;
2523
+ closeAllConfirmResolver = null;
2524
+ closeAllWindowsButton.focus({ preventScroll: true });
2525
+ resolver?.(confirmed);
2526
+ }
2527
+
2528
+ // Asks for confirmation before closing all application instances.
2529
+ function requestCloseAllWindows() {
2530
+ window.fpasoterm.confirmCloseAllWindows?.().catch((error) => {
2531
+ showDiagnostic(`close all confirmation failed: ${error}`);
2532
+ });
2533
+ }
2534
+
2535
+ // Starts or stops terminal output logging from both button clicks and shortcuts.
2536
+ function toggleTerminalOutputLog() {
2537
+ const active = terminalLogToggleButton.dataset.active === 'true';
2538
+ const action = active ? stopTerminalOutputLog() : startTerminalOutputLog();
2539
+ action.catch((error) => {
2540
+ terminalLogToggleButton.textContent = 'Error';
2541
+ setTimeout(() => refreshTerminalLogControl().catch(() => {}), 1400);
2542
+ showDiagnostic(`terminal log toggle failed: ${error}`);
2543
+ }).finally(() => setWindowMenuOpen(false));
2544
+ }
2545
+
2546
+ // Opens the terminal log viewer from both button clicks and shortcuts.
2547
+ function showTerminalOutputLogFromMenu() {
2548
+ showTerminalOutputLog().catch((error) => {
2549
+ terminalLogShowButton.textContent = 'Error';
2550
+ setTimeout(() => {
2551
+ terminalLogShowButton.textContent = 'Log Show (^P)';
2552
+ }, 1400);
2553
+ showDiagnostic(`terminal log show failed: ${error}`);
2554
+ }).finally(() => setWindowMenuOpen(false));
2555
+ }
2556
+
2557
+ // Renders checkboxes for the live local terminals that can receive broadcast input.
2558
+ function renderTerminalBroadcastTargets(targets) {
2559
+ terminalBroadcastTargets = Array.isArray(targets) ? targets : [];
2560
+ terminalBroadcastTargetList.replaceChildren();
2561
+ for (const target of terminalBroadcastTargets) {
2562
+ const label = document.createElement('label');
2563
+ const checkbox = document.createElement('input');
2564
+ checkbox.type = 'checkbox';
2565
+ checkbox.checked = true;
2566
+ checkbox.dataset.instanceId = target.id;
2567
+ checkbox.addEventListener('change', refreshTerminalBroadcastSyncOption);
2568
+ label.append(checkbox, document.createTextNode(`${target.title} (pid ${target.pid})`));
2569
+ terminalBroadcastTargetList.append(label);
2570
+ }
2571
+ if (!terminalBroadcastTargets.length) {
2572
+ terminalBroadcastTargetList.textContent = 'No running local windows were found.';
2573
+ }
2574
+ refreshTerminalBroadcastSyncOption();
2575
+ }
2576
+
2577
+ // Returns selected local IDs. An empty list deliberately means every instance.
2578
+ function selectedTerminalBroadcastTargetIds() {
2579
+ const selected = [...terminalBroadcastTargetList.querySelectorAll('input[type="checkbox"]:checked')]
2580
+ .map((checkbox) => checkbox.dataset.instanceId)
2581
+ .filter(Boolean);
2582
+ return selected.length === terminalBroadcastTargets.length ? [] : selected;
2583
+ }
2584
+
2585
+ // Allows sync delivery only when every local target is selected, so a local
2586
+ // subset cannot unexpectedly be interpreted as a remote target subset.
2587
+ function refreshTerminalBroadcastSyncOption() {
2588
+ const selectedCount = terminalBroadcastTargetList.querySelectorAll('input[type="checkbox"]:checked').length;
2589
+ const allSelected = terminalBroadcastTargets.length > 0 && selectedCount === terminalBroadcastTargets.length;
2590
+ const canSync = syncCommandsEnabled() && allSelected;
2591
+ terminalBroadcastSync.disabled = !canSync;
2592
+ terminalBroadcastSyncLabel.hidden = !syncEnabled();
2593
+ if (!canSync) {
2594
+ terminalBroadcastSync.checked = false;
2595
+ }
2596
+ terminalBroadcastSyncLabel.title = !syncCommandsEnabled()
2597
+ ? 'Run fpasoterm --setup-sync to configure a shared command secret.'
2598
+ : allSelected
2599
+ ? ''
2600
+ : 'Select all local windows before including the synced channel.';
2601
+ }
2602
+
2603
+ // Shows a focused input dialog for selecting local targets and sending one command.
2604
+ async function openTerminalBroadcastDialog() {
2605
+ if (!terminalBroadcastDialog || !terminalBroadcastText) {
2606
+ showDiagnostic('terminal broadcast dialog is unavailable');
2607
+ return;
2608
+ }
2609
+ setWindowMenuOpen(false);
2610
+ terminalBroadcastSync.checked = false;
2611
+ terminalBroadcastControl.value = '';
2612
+ terminalBroadcastControlStatus.textContent = '';
2613
+ updateTerminalBroadcastFocusStatus(terminalBroadcastText);
2614
+ terminalBroadcastTargetList.textContent = 'Loading local windows...';
2615
+ terminalBroadcastDialog.hidden = false;
2616
+ terminalBroadcastText.focus({ preventScroll: true });
2617
+ try {
2618
+ renderTerminalBroadcastTargets(await window.fpasoterm.terminalBroadcastTargets());
2619
+ } catch (error) {
2620
+ terminalBroadcastTargets = [];
2621
+ terminalBroadcastTargetList.textContent = `Could not list local windows: ${error}`;
2622
+ refreshTerminalBroadcastSyncOption();
2623
+ }
2624
+ }
2625
+
2626
+ // Hides the broadcast dialog and restores direct terminal input focus.
2627
+ function closeTerminalBroadcastDialog() {
2628
+ if (terminalBroadcastDialog) {
2629
+ terminalBroadcastDialog.hidden = true;
2630
+ }
2631
+ focusTerminalInput();
2632
+ }
2633
+
2634
+ // Converts visible Broadcast control notation back into its terminal byte.
2635
+ function decodeTerminalBroadcastControls(text) {
2636
+ const controls = {
2637
+ '\\x0d': '\r',
2638
+ '\\x1b': '\x1b',
2639
+ '\\x09': '\t',
2640
+ '\\x03': '\x03',
2641
+ '\\x04': '\x04',
2642
+ '\\x18': '\x18',
2643
+ '\\x1a': '\x1a',
2644
+ };
2645
+ return String(text || '').replace(/\\x(?:0d|1b|09|03|04|18|1a)/gi, (match) => controls[match.toLowerCase()] || match);
2646
+ }
2647
+
2648
+ // Uses xterm's active keyboard encoder instead of guessing a byte sequence.
2649
+ // This preserves a target TUI's negotiated input protocol, including CSI-u.
2650
+ function terminalBroadcastSubmitKeyEvent() {
2651
+ return { key: 'Enter', code: 'Enter', shiftKey: false };
2652
+ }
2653
+
2654
+ // A control-byte-only broadcast is used for actions such as Ctrl+C. Appending
2655
+ // Enter would perform a second, unrelated action in the target application.
2656
+ function shouldAppendTerminalBroadcastEnter(text) {
2657
+ const value = String(text || '');
2658
+ return !value.endsWith('\r') && /[^\x00-\x1f\x7f]/.test(value);
2659
+ }
2660
+
2661
+ // Delivers a semantic key press through xterm so its current keyboard mode
2662
+ // produces the exact PTY sequence expected by the target application.
2663
+ function dispatchTerminalBroadcastKey(keyEvent) {
2664
+ if (!keyEvent || !term?._core || typeof term._core._keyDown !== 'function') {
2665
+ showDiagnostic('terminal broadcast key event is unavailable');
2666
+ return;
2667
+ }
2668
+ const event = {
2669
+ type: 'keydown',
2670
+ key: keyEvent.key === 'Enter' ? 'Enter' : String(keyEvent.key || ''),
2671
+ code: keyEvent.code === 'Enter' ? 'Enter' : String(keyEvent.code || ''),
2672
+ keyCode: keyEvent.key === 'Enter' ? 13 : 0,
2673
+ shiftKey: keyEvent.shiftKey === true,
2674
+ altKey: false,
2675
+ ctrlKey: false,
2676
+ metaKey: false,
2677
+ repeat: false,
2678
+ preventDefault() {},
2679
+ stopPropagation() {},
2680
+ getModifierState() { return false; },
2681
+ };
2682
+ term._core._keyDown(event);
2683
+ }
2684
+
2685
+ // Inserts visible notation for a terminal control byte, avoiding browser keys
2686
+ // such as Escape and Tab while keeping pending Broadcast text inspectable.
2687
+ function insertTerminalBroadcastControl() {
2688
+ const control = String(terminalBroadcastControl?.value || '');
2689
+ const controls = {
2690
+ enter: { text: '\\x0D', label: 'Enter / CR (0x0D)' },
2691
+ tab: { text: '\\x09', label: 'Tab (0x09)' },
2692
+ 'ctrl-c': { text: '\\x03', label: 'Ctrl+C (0x03)' },
2693
+ 'ctrl-d': { text: '\\x04', label: 'Ctrl+D (0x04)' },
2694
+ 'ctrl-x': { text: '\\x18', label: 'Ctrl+X (0x18)' },
2695
+ 'ctrl-z': { text: '\\x1A', label: 'Ctrl+Z (0x1A)' },
2696
+ 'alt-prefix': { text: '\\x1B', label: 'Alt prefix / Esc (0x1B)' },
2697
+ };
2698
+ const selected = controls[control];
2699
+ if (!selected || !terminalBroadcastText) {
2700
+ terminalBroadcastControlStatus.textContent = 'Select a control byte first.';
2701
+ return;
2702
+ }
2703
+ terminalBroadcastText.setRangeText(
2704
+ selected.text,
2705
+ terminalBroadcastText.selectionStart,
2706
+ terminalBroadcastText.selectionEnd,
2707
+ 'end',
2708
+ );
2709
+ terminalBroadcastControlStatus.textContent = `Inserted ${selected.label}.`;
2710
+ terminalBroadcastControl.value = '';
2711
+ terminalBroadcastText.focus({ preventScroll: true });
2712
+ }
2713
+
2714
+ // Identifies commands that are destructive enough to warrant a second click.
2715
+ // This is a convenience warning, not a shell parser or a security boundary.
2716
+ function dangerousBroadcastCommandLabels(text) {
2717
+ const command = String(text || '');
2718
+ const checks = [
2719
+ ['rm', /(?:^|[;|&\n]\s*)(?:sudo\s+)?(?:\S+\/)?rm(?:\s|$)/im],
2720
+ ['find -delete', /\bfind\b[^\r\n;|&]*\s-delete\b/i],
2721
+ ['git reset --hard', /\bgit\s+reset\s+(?:--hard|-H)\b/i],
2722
+ ['git clean -f', /\bgit\s+clean\b[^\r\n;|&]*(?:\s-f|--force)\b/i],
2723
+ ['filesystem formatting', /\b(?:mkfs(?:\.[A-Za-z0-9_-]+)?|wipefs)\b/i],
2724
+ ['dd output device', /\bdd\b[^\r\n;|&]*\bof=/i],
2725
+ ['truncate or shred', /\b(?:truncate|shred)\b/i],
2726
+ ['system shutdown', /\b(?:shutdown|poweroff|reboot|halt)\b/i],
2727
+ ];
2728
+ return checks.filter(([, pattern]) => pattern.test(command)).map(([label]) => label);
2729
+ }
2730
+
2731
+ // Presents a focused confirmation before a potentially destructive Broadcast.
2732
+ function confirmDangerousBroadcast(labels, text, targetCount, includesSync) {
2733
+ if (!terminalBroadcastConfirmElement || !terminalBroadcastConfirmMessageElement || !terminalBroadcastConfirmOkButton) {
2734
+ showDiagnostic('broadcast confirmation UI is unavailable');
2735
+ return Promise.resolve(false);
2736
+ }
2737
+ terminalBroadcastConfirmMessageElement.textContent = [
2738
+ 'Potentially destructive command detected.',
2739
+ `Matches: ${labels.join(', ')}`,
2740
+ `Targets: ${targetCount} local window${targetCount === 1 ? '' : 's'}${includesSync ? ' and the synced channel' : ''}`,
2741
+ '',
2742
+ 'Command:',
2743
+ text,
2744
+ ].join('\n');
2745
+ terminalBroadcastConfirmElement.hidden = false;
2746
+ terminalBroadcastConfirmOkButton.focus({ preventScroll: true });
2747
+ return new Promise((resolve) => {
2748
+ terminalBroadcastConfirmResolver = resolve;
2749
+ });
2750
+ }
2751
+
2752
+ // Closes the Broadcast confirmation and returns focus to its editable command.
2753
+ function resolveDangerousBroadcastConfirmation(confirmed) {
2754
+ if (!terminalBroadcastConfirmElement || terminalBroadcastConfirmElement.hidden) {
2755
+ return;
2756
+ }
2757
+ terminalBroadcastConfirmElement.hidden = true;
2758
+ const resolver = terminalBroadcastConfirmResolver;
2759
+ terminalBroadcastConfirmResolver = null;
2760
+ terminalBroadcastText?.focus({ preventScroll: true });
2761
+ resolver?.(confirmed);
2762
+ }
2763
+
2764
+ // Writes the dialog text to local windows and optionally the configured sync channel.
2765
+ async function sendTerminalBroadcast() {
2766
+ const rawText = String(terminalBroadcastText?.value || '');
2767
+ const normalizedText = decodeTerminalBroadcastControls(normalizePasteText(rawText).replace(/\r+$/, ''));
2768
+ const text = normalizedText;
2769
+ const keyEvent = shouldAppendTerminalBroadcastEnter(text)
2770
+ ? terminalBroadcastSubmitKeyEvent()
2771
+ : null;
2772
+ if (!text) {
2773
+ showDiagnostic('terminal broadcast skipped: input is empty');
2774
+ terminalBroadcastText?.focus({ preventScroll: true });
2775
+ return;
2776
+ }
2777
+ const selectedCount = terminalBroadcastTargetList.querySelectorAll('input[type="checkbox"]:checked').length;
2778
+ if (!selectedCount) {
2779
+ showDiagnostic('terminal broadcast skipped: select at least one local window');
2780
+ return;
2781
+ }
2782
+ const warnings = dangerousBroadcastCommandLabels(rawText);
2783
+ if (warnings.length > 0) {
2784
+ const confirmed = await confirmDangerousBroadcast(
2785
+ warnings,
2786
+ rawText,
2787
+ selectedCount,
2788
+ Boolean(terminalBroadcastSync?.checked),
2789
+ );
2790
+ if (!confirmed) {
2791
+ showDiagnostic(`terminal broadcast cancelled: potentially destructive command (${warnings.join(', ')})`);
2792
+ return;
2793
+ }
2794
+ }
2795
+ const targetInstanceIds = selectedTerminalBroadcastTargetIds();
2796
+ const result = await window.fpasoterm.broadcastTerminal(
2797
+ text,
2798
+ Boolean(terminalBroadcastSync?.checked),
2799
+ targetInstanceIds,
2800
+ keyEvent,
2801
+ );
2802
+ showDiagnostic(result.message || `terminal broadcast requested bytes=${text.length}`);
2803
+ terminalBroadcastText.value = '';
2804
+ closeTerminalBroadcastDialog();
2805
+ }
2806
+
2807
+ // Starts terminal output logging to the configured or requested file.
2808
+ async function startTerminalOutputLog(path = '') {
2809
+ const status = await window.fpasoterm.startTerminalLog(path);
2810
+ await refreshTerminalLogControl();
2811
+ showDiagnostic(`terminal log started path=${status.path}`);
2812
+ return status;
2813
+ }
2814
+
2815
+ // Stops terminal output logging and reports the final byte count.
2816
+ async function stopTerminalOutputLog() {
2817
+ const status = await window.fpasoterm.stopTerminalLog();
2818
+ await refreshTerminalLogControl();
2819
+ showDiagnostic(`terminal log stopped path=${status.path} bytes=${status.bytesWritten}`);
2820
+ return status;
2821
+ }
2822
+
2823
+ // Shows or hides the log picker controls in the diagnostics toolbar.
2824
+ function setTerminalLogPickerVisible(visible) {
2825
+ for (const element of [
2826
+ terminalLogSelectElement,
2827
+ terminalLogSearchElement,
2828
+ terminalLogSearchNextButton,
2829
+ terminalLogSearchStatusElement,
2830
+ terminalLogShowSelectedButton,
2831
+ terminalLogDeleteSelectedButton,
2832
+ terminalLogDeleteAllButton,
2833
+ ]) {
2834
+ if (element) {
2835
+ element.hidden = !visible;
2836
+ }
2837
+ }
2838
+ }
2839
+
2840
+ // Shows the persistent decoder selector only in the capability diagnostics view.
2841
+ function setTerminalEncodingControlsVisible(visible) {
2842
+ for (const element of [terminalEncodingSelectElement, terminalEncodingApplyButton]) {
2843
+ if (element) {
2844
+ element.hidden = !visible;
2845
+ }
2846
+ }
2847
+ }
2848
+
2849
+ // Displays representative glyphs using the same configured font values as xterm.js.
2850
+ function showFontGlyphTest() {
2851
+ if (!fontGlyphPreviewElement) {
2852
+ showDiagnostic('font glyph diagnostics panel is unavailable');
2853
+ return;
2854
+ }
2855
+ const terminalConfig = appConfig.terminal || {};
2856
+ const fontFamily = String(terminalConfig.fontFamily || fallbackConfig.terminal.fontFamily);
2857
+ const fontSize = Number(terminalConfig.fontSize) || fallbackConfig.terminal.fontSize;
2858
+ const lineHeight = Number(terminalConfig.lineHeight) || fallbackConfig.terminal.lineHeight;
2859
+
2860
+ diagnosticsPanelMode = 'font-glyph-test';
2861
+ setTerminalLogPickerVisible(false);
2862
+ setTerminalEncodingControlsVisible(false);
2863
+ setWindowMenuOpen(false);
2864
+ diagnosticsTitleElement.textContent = 'Font / Glyph Diagnostics';
2865
+ diagnosticsElement.hidden = true;
2866
+ fontGlyphPreviewElement.hidden = false;
2867
+ if (terminalCapabilityPreviewElement) {
2868
+ terminalCapabilityPreviewElement.hidden = true;
2869
+ }
2870
+ fontGlyphPreviewElement.style.fontFamily = fontFamily;
2871
+ fontGlyphPreviewElement.style.fontSize = `${fontSize}px`;
2872
+ fontGlyphPreviewElement.style.lineHeight = String(lineHeight);
2873
+ fontGlyphPreviewElement.textContent = [
2874
+ 'Configured terminal font',
2875
+ `fontFamily: ${fontFamily}`,
2876
+ `fontSize: ${fontSize}`,
2877
+ `lineHeight: ${lineHeight}`,
2878
+ '',
2879
+ 'CJK: 日本語 漢字 ひらがな カタカナ 中文 한국어',
2880
+ 'Half-width kana: アイウエオ カッチョイ ハンカク カタカナ ー゙゚',
2881
+ 'Box drawing: ┌─┬─┐ │ │ │ ├─┼─┤ │ │ │ └─┴─┘ ╔═╦═╗ ║ ║ ║ ╚═╩═╝',
2882
+ 'Symbols: ← → ↑ ↓ ⇄ ✓ ✗ ★ ☆ ◆ ◇ ● ○ ± ≠ ≤ ≥',
2883
+ 'Nerd Font (Powerline):   ',
2884
+ 'Nerd Font (icons):  󰆍 󰘧 󰊢',
2885
+ 'Nerd Font code points: U+E0A0 U+E0B0 U+E0B2 U+F423 U+F010D U+F0627 U+F02A2',
2886
+ ].join('\n');
2887
+ diagnosticsPathElement.textContent = `Config: ${activeConfigPath || '(default runtime config)'}`;
2888
+ diagnosticsPanel.hidden = false;
2889
+ fontGlyphPreviewElement.focus({ preventScroll: true });
2890
+ }
2891
+
2892
+ // Shows the terminal environment and documented protocol behavior without
2893
+ // emitting control sequences into the user's active shell session.
2894
+ async function showTerminalCapabilityTest() {
2895
+ if (!terminalCapabilityPreviewElement) {
2896
+ showDiagnostic('terminal capability diagnostics panel is unavailable');
2897
+ return;
2898
+ }
2899
+ const capabilities = await window.fpasoterm.getTerminalCapabilities();
2900
+ diagnosticsPanelMode = 'terminal-capability-test';
2901
+ setTerminalLogPickerVisible(false);
2902
+ setTerminalEncodingControlsVisible(true);
2903
+ setWindowMenuOpen(false);
2904
+ diagnosticsTitleElement.textContent = 'Terminal Capability Diagnostics';
2905
+ diagnosticsElement.hidden = true;
2906
+ fontGlyphPreviewElement.hidden = true;
2907
+ terminalCapabilityPreviewElement.hidden = false;
2908
+ terminalCapabilityPreviewElement.textContent = [
2909
+ 'Terminal environment',
2910
+ `TERM: ${capabilities.term}`,
2911
+ `COLORTERM: ${capabilities.colorterm}`,
2912
+ `locale: ${capabilities.locale}`,
2913
+ `output encoding: ${capabilities.encoding || 'utf-8'}`,
2914
+ `shell: ${capabilities.shell}`,
2915
+ '',
2916
+ 'Truecolor: advertised (xterm.js renderer)',
2917
+ "Test in terminal: printf '\\033[38;2;255;80;80mred \\033[38;2;80;220;140mgreen \\033[38;2;90;150;255mblue\\033[0m\\n'",
2918
+ 'Expected: three visibly different RGB color words.',
2919
+ '',
2920
+ 'OSC 52 clipboard: supported for received OSC 52 text payloads; writes OS clipboard.',
2921
+ `OSC 52 safety: ${oscSecurityConfig().osc52}; maximum payload: ${osc52MaximumBytes()} bytes.`,
2922
+ `OSC 8 hyperlink: click a URL to show copy/open confirmation; external opening: ${oscSecurityConfig().osc8Open === true ? 'enabled' : 'disabled'}.`,
2923
+ `OSC 9/99 notifications: ${oscSecurityConfig().oscNotifications === true ? `enabled; minimum interval ${oscNotificationMinimumInterval()} ms` : 'disabled'}${oscSessionMetadata.lastNotification ? `; last: ${oscSessionMetadata.lastNotification}` : ''}.`,
2924
+ `OSC 7 current directory: ${oscSessionMetadata.cwd || '(not reported by shell)'}.`,
2925
+ `OSC 133 shell integration: ${oscSessionMetadata.commandState}; last marker: ${oscSessionMetadata.lastMarker || '(none)'}${oscSessionMetadata.lastExitCode ? `; exit: ${oscSessionMetadata.lastExitCode}` : ''}.`,
2926
+ 'Bracketed paste: xterm.js input supports bracketed paste; shell/TUI enables it with DECSET 2004.',
2927
+ 'Bell: BEL is passed to xterm.js; audible/visual feedback depends on OS and webview settings.',
2928
+ '',
2929
+ 'Output encoding: select UTF-8, Shift_JIS, or EUC-JP above. Saving updates config.toml; restart this window before the decoder and shell locale change.',
2930
+ ].join('\n');
2931
+ if (terminalEncodingSelectElement) {
2932
+ terminalEncodingSelectElement.value = capabilities.encoding || 'utf-8';
2933
+ }
2934
+ diagnosticsPathElement.textContent = `Config: ${activeConfigPath || '(default runtime config)'}`;
2935
+ diagnosticsPanel.hidden = false;
2936
+ terminalCapabilityPreviewElement.focus({ preventScroll: true });
2937
+ }
2938
+
2939
+ // Displays the application keyboard shortcuts in the existing accessible panel.
2940
+ async function showKeyboardShortcutsHelp() {
2941
+ const version = await window.fpasoterm?.getAppVersion?.().catch(() => 'unknown') || 'unknown';
2942
+ diagnosticsPanelMode = 'keyboard-shortcuts';
2943
+ setTerminalLogPickerVisible(false);
2944
+ showDiagnosticsTextArea();
2945
+ setWindowMenuOpen(false);
2946
+ diagnosticsTitleElement.textContent = 'Keyboard Shortcuts';
2947
+ diagnosticsElement.value = [
2948
+ `fpasoterm ${version}`,
2949
+ `Config: ${activeConfigPath || 'unknown'}`,
2950
+ '',
2951
+ `${keybindingLabel('logMenu')} Open the window menu at Log actions`,
2952
+ `${keybindingLabel('logToggle')} Start or stop terminal output logging`,
2953
+ `${keybindingLabel('logShow')} Show terminal output logs`,
2954
+ `${keybindingLabel('copy')} Copy selected terminal or log text`,
2955
+ `${keybindingLabel('paste')} Paste clipboard text into the terminal`,
2956
+ `${keybindingLabel('menu')} Open or close the window menu`,
2957
+ `${keybindingLabel('help')} Show this keyboard shortcut list`,
2958
+ `${keybindingLabel('newWindow')} Open a new terminal window`,
2959
+ `${keybindingLabel('openCwd')} Open a new terminal window in the OSC 7 current directory`,
2960
+ `${keybindingLabel('broadcast')} Broadcast input to local windows or the synced channel`,
2961
+ `${keybindingLabel('kill')} Kill the running terminal command and keep its shell open`,
2962
+ `${keybindingLabel('tile')} Tile all fpasoterm windows`,
2963
+ `${keybindingLabel('closeAll')} Close all fpasoterm windows after confirmation`,
2964
+ 'Check for Updates Compare this build with the npm latest release',
2965
+ 'Escape Close the current menu or panel',
2966
+ ].join('\n');
2967
+ diagnosticsElement.scrollTop = 0;
2968
+ diagnosticsPathElement.textContent = '';
2969
+ diagnosticsPanel.hidden = false;
2970
+ if (checkForUpdatesButton) {
2971
+ checkForUpdatesButton.hidden = false;
2972
+ checkForUpdatesButton.disabled = false;
2973
+ checkForUpdatesButton.textContent = 'Check for Updates';
2974
+ checkForUpdatesButton.focus({ preventScroll: true });
2975
+ } else {
2976
+ closeDiagnosticsButton.focus({ preventScroll: true });
2977
+ }
2978
+ }
2979
+
2980
+ // Displays the configured sync folder, health checks, and discovered channels
2981
+ // in the existing keyboard-accessible diagnostics panel.
2982
+ async function showSyncStatus() {
2983
+ const status = await window.fpasoterm.syncStatus();
2984
+ diagnosticsPanelMode = 'sync-status';
2985
+ setTerminalLogPickerVisible(false);
2986
+ showDiagnosticsTextArea();
2987
+ setWindowMenuOpen(false);
2988
+ diagnosticsTitleElement.textContent = 'Sync Status';
2989
+ const lines = [
2990
+ `Status: ${status.enabled ? 'enabled' : 'disabled'}`,
2991
+ `Health: ${status.health || 'unknown'}`,
2992
+ `Provider: ${status.provider || 'folder'}`,
2993
+ `Path: ${status.path || '(not configured)'}`,
2994
+ `Channel: ${status.channel || '(not configured)'}`,
2995
+ `Root: exists=${Boolean(status.rootExists)} readable=${Boolean(status.rootReadable)} writable=${Boolean(status.rootWritable)}`,
2996
+ `Diagnostics: ${Boolean(status.diagnosticsExists)} (${Number(status.diagnosticsBytes || 0)} bytes)`,
2997
+ `Command directory: ${status.commandsPath || '(not available)'}`,
2998
+ `Commands: active=${Number(status.activeCommands || 0)} stale=${Number(status.staleCommands || 0)} invalid=${Number(status.invalidCommands || 0)} temporary=${Number(status.temporaryFiles || 0)}`,
2999
+ `Message: ${status.message || ''}`,
3000
+ '',
3001
+ 'Channels:',
3002
+ ];
3003
+ const channels = Array.isArray(status.channels) ? status.channels : [];
3004
+ if (channels.length === 0) {
3005
+ lines.push(' (none found)');
3006
+ } else {
3007
+ channels.forEach((item) => lines.push(
3008
+ ` ${item.name}: diagnostics=${Boolean(item.diagnosticsExists)} active=${Number(item.activeCommands || 0)} stale=${Number(item.staleCommands || 0)} invalid=${Number(item.invalidCommands || 0)} temporary=${Number(item.temporaryFiles || 0)}`,
3009
+ ));
3010
+ }
3011
+ diagnosticsElement.value = lines.join('\n');
3012
+ diagnosticsElement.scrollTop = 0;
3013
+ diagnosticsPathElement.textContent = status.path || '';
3014
+ diagnosticsPanel.hidden = false;
3015
+ closeDiagnosticsButton.focus({ preventScroll: true });
3016
+ }
3017
+
3018
+ // Removes only stale sync command files, then refreshes the visible health view.
3019
+ async function cleanSyncFolder() {
3020
+ const result = await window.fpasoterm.syncClean();
3021
+ showDiagnostic(`sync clean ${result.message}`);
3022
+ await showSyncStatus();
3023
+ }
3024
+
3025
+ // Formats one log item for the selector dropdown.
3026
+ function terminalLogOptionLabel(item) {
3027
+ const size = Number(item.bytes || 0);
3028
+ const kib = Math.max(1, Math.ceil(size / 1024));
3029
+ return `${item.active ? '[active] ' : ''}${item.name || item.path} (${kib} KiB)`;
3030
+ }
3031
+
3032
+ // Reloads the selector dropdown and keeps the previous selection when possible.
3033
+ async function refreshTerminalLogList(selectedPath = '') {
3034
+ const items = await window.fpasoterm.listTerminalLogs();
3035
+ const previous = selectedPath || terminalLogSelectElement?.value || '';
3036
+ terminalLogSelectElement.replaceChildren();
3037
+ for (const item of items) {
3038
+ const option = document.createElement('option');
3039
+ option.value = item.path;
3040
+ option.textContent = terminalLogOptionLabel(item);
3041
+ terminalLogSelectElement.appendChild(option);
3042
+ }
3043
+ if (items.some((item) => item.path === previous)) {
3044
+ terminalLogSelectElement.value = previous;
3045
+ }
3046
+ return items;
3047
+ }
3048
+
3049
+ // Renders a terminal log preview into the diagnostics panel.
3050
+ function renderTerminalLogPreview(preview) {
3051
+ diagnosticsPanelMode = 'terminal-log';
3052
+ setTerminalLogPickerVisible(true);
3053
+ showDiagnosticsTextArea();
3054
+ if (diagnosticsTitleElement) {
3055
+ diagnosticsTitleElement.textContent = 'Terminal Log';
3056
+ }
3057
+ const lines = [];
3058
+ if (preview.message) {
3059
+ lines.push(preview.message);
3060
+ }
3061
+ if (preview.path) {
3062
+ lines.push(`path: ${preview.path}`);
3063
+ }
3064
+ if (preview.bytes === 0) {
3065
+ lines.push(`No terminal output has been captured yet. Use Log Start/Stop or ${keybindingLabel('logToggle')}, run commands, then use Log Show or ${keybindingLabel('logShow')}.`);
3066
+ }
3067
+ if (preview.truncated) {
3068
+ lines.push('The log is truncated in this view; showing the latest part only.');
3069
+ }
3070
+ if (preview.text) {
3071
+ lines.push('', preview.text);
3072
+ }
3073
+ diagnosticsPanel.hidden = false;
3074
+ diagnosticsElement.value = lines.join('\n') || 'No terminal output log found.';
3075
+ diagnosticsElement.scrollTop = 0;
3076
+ diagnosticsPathElement.textContent = preview.path || '';
3077
+ terminalLogSearchState = { query: '', text: '', matches: [], cursor: -1 };
3078
+ if (terminalLogSearchStatusElement) {
3079
+ terminalLogSearchStatusElement.textContent = '';
3080
+ }
3081
+ focusTerminalLogPanel();
3082
+ }
3083
+
3084
+ // Displays the active or most recent terminal output log in the diagnostics panel.
3085
+ async function showTerminalOutputLog(path = '') {
3086
+ await refreshTerminalLogList(path);
3087
+ const selectedPath = path || terminalLogSelectElement?.value || '';
3088
+ const preview = await window.fpasoterm.showTerminalLog(selectedPath);
3089
+ if (preview.path && terminalLogSelectElement) {
3090
+ terminalLogSelectElement.value = preview.path;
3091
+ }
3092
+ renderTerminalLogPreview(preview);
3093
+ const message = `terminal log shown path=${preview.path || '(none)'} bytes=${preview.bytes || 0} truncated=${preview.truncated ? 'true' : 'false'}`;
3094
+ console.error(message);
3095
+ window.fpasoterm?.logDiagnostic?.(message).catch(() => {});
3096
+ }
3097
+
3098
+ // Clears all terminal output logs after explicit confirmation.
3099
+ async function clearTerminalOutputLog() {
3100
+ const confirmed = await confirmTerminalLogAction(
3101
+ 'Clear all terminal output logs?\n\nThis will empty the active log file and delete all stopped terminal-*.log files in the configured log directory. This cannot be undone.',
3102
+ terminalLogDeleteAllButton,
3103
+ );
3104
+ if (!confirmed) {
3105
+ showDiagnostic('terminal log clear canceled');
3106
+ restoreLogPanelFocus(terminalLogDeleteAllButton, { repeat: true });
3107
+ return null;
3108
+ }
3109
+
3110
+ const status = await window.fpasoterm.clearTerminalLog();
3111
+ diagnosticsPanelMode = 'terminal-log';
3112
+ showDiagnosticsTextArea();
3113
+ if (diagnosticsTitleElement) {
3114
+ diagnosticsTitleElement.textContent = 'Terminal Log';
3115
+ }
3116
+ diagnosticsPanel.hidden = false;
3117
+ diagnosticsElement.value = `${status.message}\npath: ${status.path || '(none)'}\nbytes: ${status.bytesWritten || 0}`;
3118
+ diagnosticsPathElement.textContent = status.path || '';
3119
+ await refreshTerminalLogList(status.path || '');
3120
+ await refreshTerminalLogControl();
3121
+ showDiagnostic(`terminal log cleared path=${status.path || '(none)'} bytes=${status.bytesWritten || 0}`);
3122
+ restoreLogPanelFocus(terminalLogDeleteAllButton, { repeat: true });
3123
+ return status;
3124
+ }
3125
+
3126
+ // Deletes the currently selected stopped log after confirmation.
3127
+ async function deleteSelectedTerminalOutputLog() {
3128
+ const path = terminalLogSelectElement?.value || '';
3129
+ if (!path) {
3130
+ showDiagnostic('terminal log delete skipped: no log selected');
3131
+ return null;
3132
+ }
3133
+ const confirmed = await confirmTerminalLogAction(
3134
+ `Delete the selected terminal output log?\n\n${path}\n\nThis cannot be undone.`,
3135
+ terminalLogDeleteSelectedButton,
3136
+ );
3137
+ if (!confirmed) {
3138
+ showDiagnostic('terminal log delete canceled');
3139
+ restoreLogPanelFocus(terminalLogDeleteSelectedButton, { repeat: true });
3140
+ return null;
3141
+ }
3142
+ const status = await window.fpasoterm.deleteTerminalLog(path);
3143
+ await refreshTerminalLogList();
3144
+ showDiagnosticsTextArea();
3145
+ diagnosticsElement.value = `${status.message}\npath: ${status.path || '(none)'}`;
3146
+ diagnosticsPathElement.textContent = status.path || '';
3147
+ showDiagnostic(`terminal log deleted path=${status.path || '(none)'}`);
3148
+ restoreLogPanelFocus(terminalLogSelectElement?.options?.length > 0 ? terminalLogSelectElement : closeDiagnosticsButton, {
3149
+ repeat: true,
3150
+ });
3151
+ return status;
3152
+ }
3153
+
3154
+ // Hides the diagnostics/log panel when it blocks the terminal view.
3155
+ closeDiagnosticsButton.addEventListener('click', () => {
3156
+ diagnosticsPanel.hidden = true;
3157
+ focusTerminalInput();
3158
+ });
3159
+
3160
+ terminalLogToggleButton.addEventListener('click', () => {
3161
+ toggleTerminalOutputLog();
3162
+ });
3163
+
3164
+ terminalLogShowButton.addEventListener('click', () => {
3165
+ showTerminalOutputLogFromMenu();
3166
+ });
3167
+
3168
+ syncStatusButton.addEventListener('click', () => {
3169
+ showSyncStatus().catch((error) => showDiagnostic(`sync status failed: ${error}`));
3170
+ });
3171
+
3172
+ syncCleanButton.addEventListener('click', () => {
3173
+ cleanSyncFolder().catch((error) => showDiagnostic(`sync clean failed: ${error}`));
3174
+ });
3175
+
3176
+ fontGlyphTestButton.addEventListener('click', () => {
3177
+ showFontGlyphTest();
3178
+ });
3179
+
3180
+ terminalCapabilityTestButton.addEventListener('click', () => {
3181
+ showTerminalCapabilityTest().catch((error) => showDiagnostic(`terminal capability diagnostics failed: ${error}`));
3182
+ });
3183
+
3184
+ terminalEncodingApplyButton?.addEventListener('click', () => {
3185
+ const encoding = terminalEncodingSelectElement?.value || 'utf-8';
3186
+ window.fpasoterm.setTerminalEncoding(encoding).then((configPath) => {
3187
+ showDiagnostic(`terminal encoding ${encoding} saved to ${configPath}; restart this window to apply it`);
3188
+ return showTerminalCapabilityTest();
3189
+ }).catch((error) => {
3190
+ showDiagnostic(`terminal encoding save failed: ${error}`);
3191
+ });
3192
+ });
3193
+
3194
+ logMenuToggleButton.addEventListener('click', () => {
3195
+ setWindowMenuSubmenuOpen('log', logMenuItems.hidden);
3196
+ });
3197
+
3198
+ syncMenuToggleButton.addEventListener('click', () => {
3199
+ setWindowMenuSubmenuOpen('sync', syncMenuItems.hidden);
3200
+ });
3201
+
3202
+ diagnosticsMenuToggleButton.addEventListener('click', () => {
3203
+ setWindowMenuSubmenuOpen('diagnostics', diagnosticsMenuItems.hidden);
3204
+ });
3205
+
3206
+ pluginMenuToggleButton.addEventListener('click', () => {
3207
+ setWindowMenuSubmenuOpen('plugins', pluginCommandItems.hidden);
3208
+ });
3209
+
3210
+ windowActionsMenuToggleButton.addEventListener('click', () => {
3211
+ setWindowMenuSubmenuOpen('window', windowActionsMenuItems.hidden);
3212
+ });
3213
+
3214
+ terminalLogShowSelectedButton.addEventListener('click', () => {
3215
+ showTerminalOutputLog(terminalLogSelectElement?.value || '').catch((error) => {
3216
+ showDiagnostic(`terminal log selected show failed: ${error}`);
3217
+ });
3218
+ });
3219
+
3220
+ terminalLogSearchNextButton.addEventListener('click', () => {
3221
+ searchTerminalLogText();
3222
+ });
3223
+
3224
+ terminalLogSearchElement.addEventListener('keydown', (event) => {
3225
+ if (event.key !== 'Enter') {
3226
+ return;
3227
+ }
3228
+ event.preventDefault();
3229
+ searchTerminalLogText();
3230
+ });
3231
+
3232
+ terminalLogSearchElement.addEventListener('input', () => {
3233
+ terminalLogSearchState = { query: '', text: '', matches: [], cursor: -1 };
3234
+ if (terminalLogSearchStatusElement) {
3235
+ terminalLogSearchStatusElement.textContent = '';
3236
+ }
3237
+ });
3238
+
3239
+ terminalLogDeleteSelectedButton.addEventListener('click', () => {
3240
+ deleteSelectedTerminalOutputLog().catch((error) => {
3241
+ showDiagnostic(`terminal log selected delete failed: ${error}`);
3242
+ });
3243
+ });
3244
+
3245
+ terminalLogDeleteAllButton.addEventListener('click', () => {
3246
+ clearTerminalOutputLog().catch((error) => {
3247
+ showDiagnostic(`terminal log delete all failed: ${error}`);
3248
+ restoreLogPanelFocus(terminalLogDeleteAllButton, { repeat: true });
3249
+ });
3250
+ });
3251
+
3252
+ terminalLogConfirmOkButton.addEventListener('click', () => {
3253
+ resolveTerminalLogConfirm(true);
3254
+ });
3255
+
3256
+ terminalLogConfirmCancelButton.addEventListener('click', () => {
3257
+ resolveTerminalLogConfirm(false);
3258
+ });
3259
+
3260
+ closeAllConfirmOkButton.addEventListener('click', () => {
3261
+ resolveCloseAllWindows(true);
3262
+ });
3263
+
3264
+ closeAllConfirmCancelButton.addEventListener('click', () => {
3265
+ resolveCloseAllWindows(false);
3266
+ });
3267
+
3268
+ closeAllConfirmElement.addEventListener('keydown', (event) => {
3269
+ if (event.key === 'Escape') {
3270
+ event.preventDefault();
3271
+ resolveCloseAllWindows(false);
3272
+ }
3273
+ });
3274
+
3275
+ terminalCopyButton.addEventListener('click', () => {
3276
+ copyTerminalSelection().catch((error) => {
3277
+ showDiagnostic(`terminal menu copy failed: ${error}`);
3278
+ }).finally(() => setWindowMenuOpen(false));
3279
+ });
3280
+
3281
+ terminalPasteButton.addEventListener('click', () => {
3282
+ pasteClipboardToTerminal().catch((error) => {
3283
+ showDiagnostic(`terminal menu paste failed: ${error}`);
3284
+ }).finally(() => setWindowMenuOpen(false));
3285
+ });
3286
+
3287
+ // Kills the active terminal command while preserving the interactive shell.
3288
+ terminalKillButton.addEventListener('click', () => {
3289
+ window.fpasoterm.killTerminal().then(() => {
3290
+ showDiagnostic('terminal kill requested');
3291
+ }).catch((error) => {
3292
+ showDiagnostic(`terminal kill failed: ${error}`);
3293
+ }).finally(() => setWindowMenuOpen(false));
3294
+ });
3295
+
3296
+ terminalBroadcastButton.addEventListener('click', () => {
3297
+ openTerminalBroadcastDialog().catch((error) => showDiagnostic(`terminal broadcast dialog failed: ${error}`));
3298
+ });
3299
+
3300
+ terminalBroadcastSelectAllButton.addEventListener('click', () => {
3301
+ terminalBroadcastTargetList.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
3302
+ checkbox.checked = true;
3303
+ });
3304
+ refreshTerminalBroadcastSyncOption();
3305
+ });
3306
+
3307
+ terminalBroadcastControlInsertButton.addEventListener('click', () => {
3308
+ insertTerminalBroadcastControl();
3309
+ });
3310
+
3311
+ terminalBroadcastSelectNoneButton.addEventListener('click', () => {
3312
+ terminalBroadcastTargetList.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
3313
+ checkbox.checked = false;
3314
+ });
3315
+ refreshTerminalBroadcastSyncOption();
3316
+ });
3317
+
3318
+ terminalBroadcastSendButton.addEventListener('click', () => {
3319
+ sendTerminalBroadcast().catch((error) => {
3320
+ showDiagnostic(`terminal broadcast failed: ${error}`);
3321
+ terminalBroadcastText?.focus({ preventScroll: true });
3322
+ });
3323
+ });
3324
+
3325
+ terminalBroadcastCancelButton.addEventListener('click', () => {
3326
+ closeTerminalBroadcastDialog();
3327
+ });
3328
+
3329
+ terminalBroadcastConfirmOkButton.addEventListener('click', () => {
3330
+ resolveDangerousBroadcastConfirmation(true);
3331
+ });
3332
+
3333
+ terminalBroadcastConfirmCancelButton.addEventListener('click', () => {
3334
+ resolveDangerousBroadcastConfirmation(false);
3335
+ });
3336
+
3337
+ terminalUrlCopyButton?.addEventListener('click', () => {
3338
+ copyTerminalLink(pendingTerminalUrl, 'URL').finally(closeTerminalUrlDialog);
3339
+ });
3340
+
3341
+ terminalUrlOpenButton?.addEventListener('click', () => {
3342
+ openPendingTerminalUrl();
3343
+ });
3344
+
3345
+ terminalUrlCancelButton?.addEventListener('click', closeTerminalUrlDialog);
3346
+
3347
+ terminalUrlDialog?.addEventListener('keydown', (event) => {
3348
+ if (event.key === 'Escape') {
3349
+ event.preventDefault();
3350
+ closeTerminalUrlDialog();
3351
+ return;
3352
+ }
3353
+ if (event.key !== 'Tab') {
3354
+ return;
3355
+ }
3356
+ const items = terminalUrlFocusItems();
3357
+ if (items.length === 0) {
3358
+ return;
3359
+ }
3360
+ event.preventDefault();
3361
+ const index = items.indexOf(document.activeElement);
3362
+ const delta = event.shiftKey ? -1 : 1;
3363
+ items[(index + delta + items.length) % items.length].focus({ preventScroll: true });
3364
+ });
3365
+
3366
+ // Handles Broadcast keys at document capture time so xterm.js cannot reclaim
3367
+ // focus and turn a requested send into terminal input.
3368
+ function handleTerminalBroadcastKeyboard(event) {
3369
+ if (!terminalBroadcastDialog || terminalBroadcastDialog.hidden) {
3370
+ return false;
3371
+ }
3372
+
3373
+ // Candidate selection and composition confirmation belong to the IME. Do not
3374
+ // reinterpret those keys as dialog navigation or Broadcast submission.
3375
+ if (event.isComposing) {
3376
+ return false;
3377
+ }
3378
+
3379
+ if (terminalBroadcastConfirmElement && !terminalBroadcastConfirmElement.hidden) {
3380
+ if (event.key === 'Escape') {
3381
+ event.preventDefault();
3382
+ event.stopImmediatePropagation();
3383
+ resolveDangerousBroadcastConfirmation(false);
3384
+ return true;
3385
+ }
3386
+ if (event.key === 'Tab') {
3387
+ event.preventDefault();
3388
+ event.stopImmediatePropagation();
3389
+ focusTerminalBroadcastItem(event.shiftKey ? -1 : 1);
3390
+ return true;
3391
+ }
3392
+ return false;
3393
+ }
3394
+
3395
+ if (event.key === 'Escape') {
3396
+ event.preventDefault();
3397
+ event.stopImmediatePropagation();
3398
+ closeTerminalBroadcastDialog();
3399
+ return true;
3400
+ }
3401
+ if (event.key === 'Tab') {
3402
+ event.preventDefault();
3403
+ event.stopImmediatePropagation();
3404
+ focusTerminalBroadcastItem(event.shiftKey ? -1 : 1);
3405
+ return true;
3406
+ }
3407
+ if (event.key === 'Enter' && event.shiftKey && !event.isComposing) {
3408
+ event.preventDefault();
3409
+ event.stopImmediatePropagation();
3410
+ sendTerminalBroadcast().catch((error) => showDiagnostic(`terminal broadcast failed: ${error}`));
3411
+ return true;
3412
+ }
3413
+ return false;
3414
+ }
3415
+
3416
+ terminalBroadcastConfirmElement.addEventListener('keydown', (event) => {
3417
+ handleTerminalBroadcastKeyboard(event);
3418
+ });
3419
+
3420
+ terminalBroadcastDialog.addEventListener('keydown', (event) => {
3421
+ handleTerminalBroadcastKeyboard(event);
3422
+ });
3423
+
3424
+ terminalBroadcastDialog.addEventListener('focusin', (event) => {
3425
+ updateTerminalBroadcastFocusStatus(event.target);
3426
+ });
3427
+
3428
+ document.addEventListener('keydown', (event) => {
3429
+ if (handleTerminalBroadcastKeyboard(event)) {
3430
+ return;
3431
+ }
3432
+
3433
+ if (handleWindowMenuKeyboard(event)) {
3434
+ return;
3435
+ }
3436
+
3437
+ if (matchesKeybinding(event, 'logMenu')) {
3438
+ event.preventDefault();
3439
+ const open = windowMenuItems?.hidden !== false;
3440
+ setWindowMenuOpen(open, terminalLogToggleButton);
3441
+ if (open) {
3442
+ setWindowMenuSubmenuOpen('log', true, terminalLogToggleButton);
3443
+ }
3444
+ return;
3445
+ }
3446
+
3447
+ if (matchesKeybinding(event, 'menu')) {
3448
+ event.preventDefault();
3449
+ setWindowMenuOpen(windowMenuItems?.hidden !== false);
3450
+ return;
3451
+ }
3452
+
3453
+ if (matchesKeybinding(event, 'help')) {
3454
+ event.preventDefault();
3455
+ showKeyboardShortcutsHelp().catch((error) => showDiagnostic(`help failed: ${error}`));
3456
+ return;
3457
+ }
3458
+
3459
+ if (matchesKeybinding(event, 'logToggle')) {
3460
+ event.preventDefault();
3461
+ toggleTerminalOutputLog();
3462
+ return;
3463
+ }
3464
+
3465
+ if (matchesKeybinding(event, 'logShow')) {
3466
+ event.preventDefault();
3467
+ showTerminalOutputLogFromMenu();
3468
+ }
3469
+ }, true);
3470
+
3471
+ diagnosticsPanel.addEventListener('keydown', (event) => {
3472
+ if (diagnosticsPanel.hidden) {
3473
+ return;
3474
+ }
3475
+
3476
+ if (terminalLogConfirmElement && !terminalLogConfirmElement.hidden) {
3477
+ if (event.key === 'Escape') {
3478
+ event.preventDefault();
3479
+ resolveTerminalLogConfirm(false);
3480
+ return;
3481
+ }
3482
+
3483
+ if (event.key === 'Tab') {
3484
+ event.preventDefault();
3485
+ focusDiagnosticsPanelItem(event.shiftKey ? -1 : 1);
3486
+ }
3487
+ return;
3488
+ }
3489
+
3490
+ if (event.key === 'Escape') {
3491
+ event.preventDefault();
3492
+ diagnosticsPanel.hidden = true;
3493
+ focusTerminalInput();
3494
+ return;
3495
+ }
3496
+
3497
+ if (event.key === 'Tab') {
3498
+ event.preventDefault();
3499
+ focusDiagnosticsPanelItem(event.shiftKey ? -1 : 1);
3500
+ return;
3501
+ }
3502
+
3503
+ const isSearchInput = event.target === terminalLogSearchElement;
3504
+ const isLogSelector = event.target === terminalLogSelectElement;
3505
+ if (!isSearchInput && !isLogSelector && event.key.toLowerCase() === 'n') {
3506
+ event.preventDefault();
3507
+ searchTerminalLogText(1);
3508
+ return;
3509
+ }
3510
+
3511
+ if (!isSearchInput && !isLogSelector && event.key.toLowerCase() === 'p') {
3512
+ event.preventDefault();
3513
+ searchTerminalLogText(-1);
3514
+ return;
3515
+ }
3516
+
3517
+ if (event.target === diagnosticsElement) {
3518
+ if (event.key.toLowerCase() === 'j') {
3519
+ event.preventDefault();
3520
+ searchTerminalLogText(1);
3521
+ return;
3522
+ }
3523
+
3524
+ if (event.key.toLowerCase() === 'k') {
3525
+ event.preventDefault();
3526
+ searchTerminalLogText(-1);
3527
+ }
3528
+ return;
3529
+ }
3530
+
3531
+ if (
3532
+ event.target === terminalLogSelectElement ||
3533
+ event.target === terminalLogSearchElement
3534
+ ) {
3535
+ return;
3536
+ }
3537
+
3538
+ if (['ArrowRight', 'ArrowDown'].includes(event.key)) {
3539
+ event.preventDefault();
3540
+ focusDiagnosticsPanelItem(1);
3541
+ return;
3542
+ }
3543
+
3544
+ if (['ArrowLeft', 'ArrowUp'].includes(event.key)) {
3545
+ event.preventDefault();
3546
+ focusDiagnosticsPanelItem(-1);
3547
+ }
3548
+ });
3549
+
3550
+ document.addEventListener('pointerdown', (event) => {
3551
+ if (windowMenu && !windowMenuItems.hidden && !windowMenu.contains(event.target)) {
3552
+ setWindowMenuOpen(false);
3553
+ }
3554
+ });
3555
+
3556
+
3557
+ // Confirms a close that would leave a managed remote filesystem mounted.
3558
+ async function requestWindowClose() {
3559
+ const status = await window.fpasoterm.getSshfsStatus?.();
3560
+ if (status?.mounts?.length && !await confirmSshfsWindowClose(status.mounts)) return;
3561
+ window.fpasoterm.closeWindow();
3562
+ }
3563
+
3564
+ // Reuses the app's keyboard-accessible confirmation overlay instead of a native browser dialog.
3565
+ function confirmSshfsWindowClose(mounts) {
3566
+ closeAllConfirmMessageElement.textContent =
3567
+ `Keep ${mounts.length} SSHFS mount(s) active after closing this terminal window?\n\nUse Window > SSHFS Mounts to unmount them later.`;
3568
+ closeAllConfirmElement.hidden = false;
3569
+ closeAllConfirmOkButton.focus({ preventScroll: true });
3570
+ return new Promise((resolve) => { closeAllConfirmResolver = resolve; });
3571
+ }
3572
+
3573
+ // Closes the frameless window from the custom titlebar.
3574
+ closeWindowButton.addEventListener('click', () => {
3575
+ requestWindowClose().catch((error) => showDiagnostic(`window close failed: ${error}`));
3576
+ });
3577
+
3578
+ // Minimizes the frameless window from the custom titlebar.
3579
+ minimizeWindowButton.addEventListener('click', () => {
3580
+ window.fpasoterm.minimizeWindow?.().catch((error) => {
3581
+ showDiagnostic(`window minimize failed: ${error}`);
3582
+ });
3583
+ });
3584
+
3585
+ // Toggles maximized state from the custom titlebar.
3586
+ maximizeWindowButton.addEventListener('click', () => {
3587
+ window.fpasoterm.toggleMaximizeWindow?.().catch((error) => {
3588
+ showDiagnostic(`window maximize failed: ${error}`);
3589
+ });
3590
+ });
3591
+
3592
+ // Starts another fpasoterm process for a separate terminal window.
3593
+ newWindowButton.addEventListener('click', () => {
3594
+ setWindowMenuOpen(false);
3595
+ window.fpasoterm.newWindow?.().catch((error) => showDiagnostic(`new window failed: ${error}`));
3596
+ });
3597
+
3598
+ newWindowCwdButton.addEventListener('click', () => {
3599
+ setWindowMenuOpen(false);
3600
+ openWindowAtCurrentDirectory();
3601
+ });
3602
+
3603
+ function sshfsDialogValue(name) {
3604
+ return document.getElementById(`sshfs-manager-${name}`).value.trim();
3605
+ }
3606
+
3607
+ function setSshfsDialogResult(message) {
3608
+ sshfsManagerResult.textContent = String(message);
3609
+ sshfsManagerResult.focus({ preventScroll: true });
3610
+ // The result is above the form controls. Bring it back into view after a
3611
+ // failed operation so a short native window does not hide the error.
3612
+ sshfsManagerResult.scrollIntoView({ block: 'nearest', inline: 'nearest' });
3613
+ }
3614
+
3615
+ let sshfsDialogMounts = [];
3616
+ let sshfsDialogMountRoot = '';
3617
+
3618
+ // Keeps the managed local mount path visible and copyable after a refresh.
3619
+ function setSshfsDialogLocalPath(path) {
3620
+ const value = String(path || '').trim();
3621
+ sshfsManagerLocalPathElement.textContent = value || 'Not mounted';
3622
+ sshfsManagerLocalPathElement.dataset.path = value;
3623
+ if (!value) {
3624
+ sshfsManagerLocalPathElement.removeAttribute('href');
3625
+ sshfsManagerLocalPathElement.removeAttribute('title');
3626
+ sshfsManagerLocalPathElement.setAttribute('aria-disabled', 'true');
3627
+ return;
3628
+ }
3629
+ const normalized = value.replaceAll('\\', '/');
3630
+ sshfsManagerLocalPathElement.href = normalized.startsWith('/')
3631
+ ? `file://${encodeURI(normalized)}`
3632
+ : `file:///${encodeURI(normalized)}`;
3633
+ sshfsManagerLocalPathElement.title = 'Click to copy this path';
3634
+ sshfsManagerLocalPathElement.setAttribute('aria-disabled', 'false');
3635
+ }
3636
+
3637
+ // Clears the one-time password and restores the safer masked presentation.
3638
+ function clearSshfsPassword() {
3639
+ sshfsManagerPasswordElement.value = '';
3640
+ sshfsManagerPasswordElement.type = 'password';
3641
+ sshfsManagerPasswordToggleButton.textContent = 'Show';
3642
+ sshfsManagerPasswordToggleButton.setAttribute('aria-pressed', 'false');
3643
+ }
3644
+
3645
+ // Windows records a drive letter while Unix records use the managed mount root.
3646
+ function sshfsDialogMountPath(mount) {
3647
+ if (mount.mountPoint) return mount.mountPoint;
3648
+ if (!sshfsDialogMountRoot) return '';
3649
+ const separator = sshfsDialogMountRoot.includes('\\') ? '\\' : '/';
3650
+ return `${sshfsDialogMountRoot.replace(/[\\/]$/, '')}${separator}${mount.mountName}`;
3651
+ }
3652
+
3653
+ function applySshfsDialogMount(name) {
3654
+ const mount = sshfsDialogMounts.find((item) => item.mountName === name);
3655
+ if (!mount) return;
3656
+ document.getElementById('sshfs-manager-host').value = mount.host;
3657
+ document.getElementById('sshfs-manager-user').value = mount.user;
3658
+ document.getElementById('sshfs-manager-port').value = mount.port;
3659
+ document.getElementById('sshfs-manager-remote-path').value = mount.remotePath;
3660
+ document.getElementById('sshfs-manager-mount-name').value = mount.mountName;
3661
+ document.getElementById('sshfs-manager-identity-file').value = mount.identityFile || '';
3662
+ setSshfsDialogLocalPath(sshfsDialogMountPath(mount));
3663
+ clearSshfsPassword();
3664
+ }
3665
+
3666
+ // Unmounting and forgetting must target an explicit saved record, never a
3667
+ // stale value left in the editable mount-name field after another selection.
3668
+ function selectedSshfsMountName() {
3669
+ const mountName = sshfsManagerSavedMounts.value;
3670
+ return sshfsDialogMounts.some((mount) => mount.mountName === mountName) ? mountName : '';
3671
+ }
3672
+
3673
+ async function refreshSshfsDialog(showResult = true) {
3674
+ const status = await window.fpasoterm.getSshfsStatus();
3675
+ const selected = sshfsManagerSavedMounts.value;
3676
+ sshfsDialogMountRoot = status.mountRoot || '';
3677
+ sshfsDialogMounts = Array.isArray(status.mounts) ? status.mounts : [];
3678
+ const activeMountNames = new Set(Array.isArray(status.activeMountNames) ? status.activeMountNames : []);
3679
+ sshfsManagerSavedMounts.replaceChildren(new Option('Select a saved mount', ''));
3680
+ sshfsDialogMounts.forEach((mount) => sshfsManagerSavedMounts.add(
3681
+ new Option(`${mount.mountName}: ${mount.user}@${mount.host}:${mount.remotePath}${activeMountNames.has(mount.mountName) ? '' : ' (not mounted)'}`, mount.mountName),
3682
+ ));
3683
+ sshfsManagerSavedMounts.value = sshfsDialogMounts.some((mount) => mount.mountName === selected) ? selected : '';
3684
+ if (!sshfsManagerSavedMounts.value) setSshfsDialogLocalPath('');
3685
+ if (showResult) setSshfsDialogResult(`${status.available ? 'sshfs is available' : 'sshfs was not found'}; program: ${status.programPath}; active: ${activeMountNames.size}; saved: ${sshfsDialogMounts.length}`);
3686
+ refreshSshfsMountStatus();
3687
+ }
3688
+
3689
+ // Uses the same in-window modal on every platform, avoiding a fragile second WebView.
3690
+ async function openSshfsManagerModal() {
3691
+ sshfsManagerDialog.hidden = false;
3692
+ sshfsManagerDialog.scrollTop = 0;
3693
+ try {
3694
+ await refreshSshfsDialog();
3695
+ if (sshfsDialogMounts.length === 1) {
3696
+ sshfsManagerSavedMounts.value = sshfsDialogMounts[0].mountName;
3697
+ applySshfsDialogMount(sshfsDialogMounts[0].mountName);
3698
+ }
3699
+ (sshfsManagerSavedMounts.value ? sshfsManagerSavedMounts : document.getElementById('sshfs-manager-host')).focus({ preventScroll: true });
3700
+ } catch (error) {
3701
+ setSshfsDialogResult(`SSHFS check failed: ${error}`);
3702
+ }
3703
+ }
3704
+
3705
+ function closeSshfsManagerModal() {
3706
+ clearSshfsPassword();
3707
+ sshfsManagerDialog.hidden = true;
3708
+ sshfsManagerButton.focus({ preventScroll: true });
3709
+ terminalElement.focus({ preventScroll: true });
3710
+ }
3711
+
3712
+ sshfsManagerSavedMounts.addEventListener('change', () => applySshfsDialogMount(sshfsManagerSavedMounts.value));
3713
+ // Path links deliberately use the WebView clipboard only. Calling the backend
3714
+ // fallback can launch PowerShell when the native clipboard is temporarily busy.
3715
+ sshfsManagerLocalPathElement.addEventListener('click', async (event) => {
3716
+ event.preventDefault();
3717
+ const path = sshfsManagerLocalPathElement.dataset.path || '';
3718
+ if (!path) return;
3719
+ try {
3720
+ await writeBrowserClipboardText(path);
3721
+ setSshfsDialogResult(`Local path copied: ${path}`);
3722
+ } catch (error) {
3723
+ setSshfsDialogResult(`Could not copy local path: ${error}`);
3724
+ }
3725
+ });
3726
+ // Allows a user to verify a one-time password without changing its persistence policy.
3727
+ sshfsManagerPasswordToggleButton.addEventListener('click', () => {
3728
+ const visible = sshfsManagerPasswordElement.type === 'text';
3729
+ sshfsManagerPasswordElement.type = visible ? 'password' : 'text';
3730
+ sshfsManagerPasswordToggleButton.textContent = visible ? 'Show' : 'Hide';
3731
+ sshfsManagerPasswordToggleButton.setAttribute('aria-pressed', String(!visible));
3732
+ sshfsManagerPasswordElement.focus({ preventScroll: true });
3733
+ });
3734
+ sshfsManagerForm.addEventListener('submit', async (event) => {
3735
+ event.preventDefault();
3736
+ try {
3737
+ const mounted = await window.fpasoterm.mountSshfs({
3738
+ host: sshfsDialogValue('host'), user: sshfsDialogValue('user'), port: Number(sshfsDialogValue('port')),
3739
+ remotePath: sshfsDialogValue('remote-path'), mountName: sshfsDialogValue('mount-name'),
3740
+ identityFile: sshfsDialogValue('identity-file') || null,
3741
+ password: sshfsManagerPasswordElement.value || null,
3742
+ });
3743
+ clearSshfsPassword();
3744
+ setSshfsDialogLocalPath(mounted.mountPoint);
3745
+ setSshfsDialogResult(`${mounted.message}: ${mounted.mountPoint}`);
3746
+ await refreshSshfsDialog(false);
3747
+ // A successful mount needs no further form input. Close the manager after
3748
+ // persisting the record; reopening it keeps the drive path and Unmount UI.
3749
+ closeSshfsManagerModal();
3750
+ showDiagnostic(`SSHFS mount ready: ${mounted.mountPoint}`);
3751
+ } catch (error) {
3752
+ clearSshfsPassword();
3753
+ setSshfsDialogResult(`Mount failed: ${error}`);
3754
+ }
3755
+ });
3756
+ sshfsManagerUnmountButton.addEventListener('click', async () => {
3757
+ const mountName = selectedSshfsMountName();
3758
+ if (!mountName) return setSshfsDialogResult('Select the saved mount to unmount.');
3759
+ try { const result = await window.fpasoterm.unmountSshfs(mountName); setSshfsDialogResult(`${result.message}: ${result.mountPoint}`); await refreshSshfsDialog(false); }
3760
+ catch (error) { setSshfsDialogResult(`Unmount failed: ${error}`); }
3761
+ });
3762
+ sshfsManagerUnmountAllButton.addEventListener('click', async () => {
3763
+ if (!sshfsDialogMounts.length) return setSshfsDialogResult('No saved SSHFS mounts to remove.');
3764
+ try { setSshfsDialogResult(await window.fpasoterm.unmountAllSshfs()); await refreshSshfsDialog(false); }
3765
+ catch (error) { setSshfsDialogResult(`Unmount all failed: ${error}`); }
3766
+ });
3767
+ sshfsManagerForgetButton.addEventListener('click', async () => {
3768
+ const mountName = selectedSshfsMountName();
3769
+ if (!mountName) return setSshfsDialogResult('Select the saved mount to forget.');
3770
+ try {
3771
+ const result = await window.fpasoterm.forgetSshfs(mountName);
3772
+ setSshfsDialogResult(`${result.message}: ${result.mountPoint}`);
3773
+ await refreshSshfsDialog(false);
3774
+ } catch (error) {
3775
+ setSshfsDialogResult(`Forget saved mount failed: ${error}`);
3776
+ }
3777
+ });
3778
+ sshfsManagerCloseButton.addEventListener('click', closeSshfsManagerModal);
3779
+ sshfsManagerDialog.addEventListener('keydown', (event) => {
3780
+ if (event.key === 'Escape') { event.preventDefault(); closeSshfsManagerModal(); }
3781
+ });
3782
+
3783
+ // Opens the shared SSHFS manager without creating another native WebView window.
3784
+ sshfsManagerButton.addEventListener('click', () => {
3785
+ setWindowMenuOpen(false);
3786
+ openSshfsManagerModal();
3787
+ });
3788
+
3789
+ // Requests a grid layout for all currently running fpasoterm windows.
3790
+ arrangeWindowButton.addEventListener('click', () => {
3791
+ setWindowMenuOpen(false);
3792
+ window.fpasoterm.arrangeWindows?.(getAvailableScreenBounds())
3793
+ .then((message) => showDiagnostic(message))
3794
+ .catch((error) => showDiagnostic(`window arrange failed: ${error}`));
3795
+ });
3796
+
3797
+ // Broadcasts a close request to every running fpasoterm process.
3798
+ closeAllWindowsButton.addEventListener('click', () => {
3799
+ setWindowMenuOpen(false);
3800
+ requestCloseAllWindows();
3801
+ });
3802
+
3803
+ // Opens the keyboard shortcut reference from the window menu.
3804
+ keyboardShortcutsHelpButton.addEventListener('click', () => {
3805
+ showKeyboardShortcutsHelp().catch((error) => showDiagnostic(`help failed: ${error}`));
3806
+ });
3807
+
3808
+ // Queries npm only after an explicit Help-panel action, preserving offline startup behavior.
3809
+ checkForUpdatesButton?.addEventListener('click', async () => {
3810
+ checkForUpdatesButton.disabled = true;
3811
+ checkForUpdatesButton.textContent = 'Checking...';
3812
+ try {
3813
+ const status = await window.fpasoterm.checkForUpdate();
3814
+ const result = status.updateAvailable
3815
+ ? 'Status: update available. Run: fpasoterm --self-update'
3816
+ : status.localBuildNewer
3817
+ ? 'Status: local build is newer than npm latest.'
3818
+ : 'Status: up to date.';
3819
+ diagnosticsElement.value = [
3820
+ diagnosticsElement.value,
3821
+ '',
3822
+ `Installed: ${status.installed}`,
3823
+ `Latest npm release: ${status.latest}`,
3824
+ result,
3825
+ ].join('\n');
3826
+ diagnosticsElement.scrollTop = diagnosticsElement.scrollHeight;
3827
+ checkForUpdatesButton.textContent = status.updateAvailable ? 'Update Available' : 'Up to Date';
3828
+ } catch (error) {
3829
+ diagnosticsElement.value = `${diagnosticsElement.value}\n\nUpdate check failed: ${error}`;
3830
+ diagnosticsElement.scrollTop = diagnosticsElement.scrollHeight;
3831
+ checkForUpdatesButton.textContent = 'Check Failed';
3832
+ showDiagnostic(`update check failed: ${error}`);
3833
+ } finally {
3834
+ checkForUpdatesButton.disabled = false;
3835
+ }
3836
+ });
3837
+
3838
+ // Reports the logical work area so ChromeOS shelf and display scaling are not
3839
+ // mistaken for usable native monitor pixels during window tiling.
3840
+ function getAvailableScreenBounds() {
3841
+ const screen = window.screen;
3842
+ return {
3843
+ width: screen.availWidth,
3844
+ height: screen.availHeight,
3845
+ left: screen.availLeft || 0,
3846
+ top: screen.availTop || 0,
3847
+ devicePixelRatio: window.devicePixelRatio || 1,
3848
+ };
3849
+ }
3850
+
3851
+ // Opens or closes the compact window action menu.
3852
+ function setWindowMenuOpen(open, preferredItem = newWindowButton) {
3853
+ if (!windowMenuItems || !windowMenuToggleButton) {
3854
+ return;
3855
+ }
3856
+ windowMenuItems.hidden = !open;
3857
+ windowMenuToggleButton.setAttribute('aria-expanded', open ? 'true' : 'false');
3858
+ if (!open) {
3859
+ closeWindowMenuSubmenus();
3860
+ }
3861
+ if (open) {
3862
+ closeWindowMenuSubmenus();
3863
+ fitWindowMenuToViewport();
3864
+ const focusTarget = preferredItem && !preferredItem.hidden && !preferredItem.disabled
3865
+ ? preferredItem
3866
+ : newWindowButton;
3867
+ focusTarget.focus();
3868
+ // xterm/WebKit can reclaim focus after the shortcut's key event finishes.
3869
+ // Repeat on the next frame so keyboard navigation always starts in the menu.
3870
+ requestAnimationFrame(() => {
3871
+ if (!windowMenuItems.hidden && focusTarget.getClientRects().length > 0) {
3872
+ focusTarget.focus({ preventScroll: true });
3873
+ }
3874
+ });
3875
+ }
3876
+ }
3877
+
3878
+ windowMenuToggleButton.addEventListener('click', () => {
3879
+ setWindowMenuOpen(windowMenuItems.hidden);
3880
+ });
3881
+
3882
+ windowMenuToggleButton.addEventListener('keydown', (event) => {
3883
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
3884
+ event.preventDefault();
3885
+ setWindowMenuOpen(true);
3886
+ }
3887
+ });
3888
+
3889
+ // Handles menu navigation before xterm.js consumes keys when the menu is open.
3890
+ function handleWindowMenuKeyboard(event) {
3891
+ if (!windowMenuItems || windowMenuItems.hidden) {
3892
+ return false;
3893
+ }
3894
+
3895
+ if (event.key === 'Escape') {
3896
+ event.preventDefault();
3897
+ event.stopImmediatePropagation();
3898
+ setWindowMenuOpen(false);
3899
+ focusTerminalInput();
3900
+ return true;
3901
+ }
3902
+ if (event.key === 'Tab' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
3903
+ event.preventDefault();
3904
+ event.stopImmediatePropagation();
3905
+ const backwards = event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey);
3906
+ focusWindowMenuItem(backwards ? -1 : 1);
3907
+ return true;
3908
+ }
3909
+
3910
+ const submenu = windowMenuSubmenuForElement(document.activeElement);
3911
+ if (event.key === 'ArrowRight' && document.activeElement?.classList.contains('window-menu-submenu-toggle')) {
3912
+ event.preventDefault();
3913
+ event.stopImmediatePropagation();
3914
+ const firstChild = submenu?.items.querySelector('button:not([hidden]):not(:disabled)');
3915
+ setWindowMenuSubmenuOpen(submenu?.name, true, firstChild);
3916
+ return true;
3917
+ }
3918
+
3919
+ if (event.key === 'ArrowLeft' && submenu && document.activeElement !== submenu.toggle) {
3920
+ event.preventDefault();
3921
+ event.stopImmediatePropagation();
3922
+ setWindowMenuSubmenuOpen(submenu.name, false);
3923
+ submenu.toggle.focus({ preventScroll: true });
3924
+ return true;
3925
+ }
3926
+
3927
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
3928
+ event.preventDefault();
3929
+ event.stopImmediatePropagation();
3930
+ focusWindowMenuItem(event.key === 'ArrowRight' ? 1 : -1);
3931
+ return true;
3932
+ }
3933
+ return false;
3934
+ }
3935
+
3936
+ windowMenuItems.addEventListener('keydown', (event) => {
3937
+ handleWindowMenuKeyboard(event);
3938
+ });
3939
+
3940
+ window.addEventListener('resize', fitWindowMenuToViewport);
3941
+
3942
+ // Starts native window dragging from the custom titlebar on Tauri.
3943
+ document.getElementById('drag-region').addEventListener('pointerdown', (event) => {
3944
+ if (event.button !== 0 || event.target.closest('#window-controls')) {
3945
+ return;
3946
+ }
3947
+ event.preventDefault();
3948
+ window.fpasoterm.startWindowDrag?.().catch((error) => {
3949
+ showDiagnostic(`window drag failed: ${error}`);
3950
+ });
3951
+ });
3952
+
3953
+ // Converts DOM direction names into Tauri's native resize directions.
3954
+ function toTauriResizeDirection(direction) {
3955
+ return direction
3956
+ .split(/(?=[A-Z])/)
3957
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
3958
+ .join('');
3959
+ }
3960
+
3961
+ // Uses the platform-native resize loop first so the window manager owns pointer capture.
3962
+ async function startWindowResize(event, direction) {
3963
+ try {
3964
+ await window.fpasoterm.startWindowResizeDrag?.(direction);
3965
+ } catch (error) {
3966
+ showDiagnostic(`native window resize failed direction=${direction}: ${error}`);
3967
+ await startManualWindowResize(event, direction);
3968
+ }
3969
+ }
3970
+
3971
+ // Resizes frameless windows without relying on platform-specific native hit testing.
3972
+ async function startManualWindowResize(event, direction) {
3973
+ const initialBounds = await window.fpasoterm.getWindowBounds();
3974
+ const startX = event.screenX;
3975
+ const startY = event.screenY;
3976
+ const minWidth = Number(appConfig.window?.minWidth) || 420;
3977
+ const minHeight = Number(appConfig.window?.minHeight) || 260;
3978
+ let latestEvent = event;
3979
+ let applying = false;
3980
+
3981
+ const applyResize = () => {
3982
+ applying = false;
3983
+ const deltaX = latestEvent.screenX - startX;
3984
+ const deltaY = latestEvent.screenY - startY;
3985
+ let x = initialBounds.x;
3986
+ let y = initialBounds.y;
3987
+ let width = initialBounds.width;
3988
+ let height = initialBounds.height;
3989
+
3990
+ if (direction.includes('East')) {
3991
+ width = initialBounds.width + deltaX;
3992
+ }
3993
+ if (direction.includes('South')) {
3994
+ height = initialBounds.height + deltaY;
3995
+ }
3996
+ if (direction.includes('West')) {
3997
+ width = initialBounds.width - deltaX;
3998
+ x = initialBounds.x + deltaX;
3999
+ }
4000
+ if (direction.includes('North')) {
4001
+ height = initialBounds.height - deltaY;
4002
+ y = initialBounds.y + deltaY;
4003
+ }
4004
+
4005
+ if (width < minWidth) {
4006
+ if (direction.includes('West')) {
4007
+ x = initialBounds.x + initialBounds.width - minWidth;
4008
+ }
4009
+ width = minWidth;
4010
+ }
4011
+ if (height < minHeight) {
4012
+ if (direction.includes('North')) {
4013
+ y = initialBounds.y + initialBounds.height - minHeight;
4014
+ }
4015
+ height = minHeight;
4016
+ }
4017
+
4018
+ window.fpasoterm.setWindowBounds({
4019
+ x: Math.round(x),
4020
+ y: Math.round(y),
4021
+ width: Math.round(width),
4022
+ height: Math.round(height),
4023
+ }).catch((error) => {
4024
+ showDiagnostic(`manual window resize failed direction=${direction}: ${error}`);
4025
+ });
4026
+ };
4027
+
4028
+ const onPointerMove = (moveEvent) => {
4029
+ latestEvent = moveEvent;
4030
+ if (!applying) {
4031
+ applying = true;
4032
+ requestAnimationFrame(applyResize);
4033
+ }
4034
+ };
4035
+ const stopResize = () => {
4036
+ window.removeEventListener('pointermove', onPointerMove);
4037
+ window.removeEventListener('pointerup', stopResize);
4038
+ window.removeEventListener('pointercancel', stopResize);
4039
+ if (latestEvent !== event) {
4040
+ applyResize();
4041
+ }
4042
+ scheduleWindowStateSave();
4043
+ fitAndResize();
4044
+ };
4045
+
4046
+ window.addEventListener('pointermove', onPointerMove);
4047
+ window.addEventListener('pointerup', stopResize, { once: true });
4048
+ window.addEventListener('pointercancel', stopResize, { once: true });
4049
+ }
4050
+
4051
+ // Adds native resize hit targets for the frameless transparent Tauri window.
4052
+ for (const handle of document.querySelectorAll('[data-resize-direction]')) {
4053
+ handle.addEventListener('pointerdown', (event) => {
4054
+ if (event.button !== 0) {
4055
+ return;
4056
+ }
4057
+
4058
+ event.preventDefault();
4059
+ const direction = toTauriResizeDirection(handle.getAttribute('data-resize-direction'));
4060
+ startWindowResize(event, direction).catch((error) => {
4061
+ showDiagnostic(`manual window resize setup failed direction=${direction}: ${error}`);
4062
+ });
4063
+ });
4064
+ }
4065
+
4066
+ // Initializes config, terminal, plugin loading, IPC handlers, and first PTY startup.
4067
+ async function initialize() {
4068
+ installTauriApiAdapter();
4069
+ if (!window.fpasoterm) {
4070
+ showTerminalError('fpasoterm backend API is not available. Start the app through the Tauri runtime.');
4071
+ return;
4072
+ }
4073
+ await loadRuntimeConfig();
4074
+ pluginVersion = await window.fpasoterm.getAppVersion?.().catch(() => 'unknown') || 'unknown';
4075
+ await installSyncControls();
4076
+ await refreshTerminalLogControl();
4077
+ await refreshSshfsMountStatus();
4078
+ window.setInterval(refreshSshfsMountStatus, 5000);
4079
+ if (debugKeys) {
4080
+ window.fpasoterm.getDiagnosticsPath().then((logPath) => {
4081
+ diagnosticsPathElement.textContent = logPath;
4082
+ });
4083
+ }
4084
+ applyWindowAppearance();
4085
+ createTerminal();
4086
+
4087
+ if (!terminalElement || !term) {
4088
+ showDiagnostic('terminal element missing');
4089
+ return;
4090
+ }
4091
+
4092
+ window.addEventListener('resize', () => {
4093
+ scheduleFitAndResize();
4094
+ scheduleWindowStateSave();
4095
+ });
4096
+
4097
+ term.onData((data) => {
4098
+ sendTerminalInput(data, 'input');
4099
+ });
4100
+
4101
+ Promise.resolve(window.fpasoterm.onTerminalData((data) => {
4102
+ queueTerminalOutput(data);
4103
+ })).catch((error) => {
4104
+ showDiagnostic(`terminal data listener failed: ${error}`);
4105
+ });
4106
+
4107
+ Promise.resolve(window.fpasoterm.onTerminalBroadcastKey((keyEvent) => {
4108
+ dispatchTerminalBroadcastKey(keyEvent);
4109
+ })).catch((error) => {
4110
+ showDiagnostic(`terminal broadcast key listener failed: ${error}`);
4111
+ });
4112
+ showDebugDiagnostic('renderer terminal data listener requested');
4113
+
4114
+ Promise.resolve(window.fpasoterm.onTerminalExit((exitCode) => {
4115
+ term.writeln('');
4116
+ term.writeln(`[process exited with code ${exitCode}]`);
4117
+ })).catch((error) => {
4118
+ showDiagnostic(`terminal exit listener failed: ${error}`);
4119
+ });
4120
+
4121
+ Promise.resolve(window.fpasoterm.onDiagnosticEvent((event) => {
4122
+ const message = `${event.source}: ${event.message}`;
4123
+ console.error(message);
4124
+ appendDiagnosticLine(message);
4125
+ scheduleSyncDiagnosticsWrite();
4126
+ })).catch((error) => {
4127
+ showDiagnostic(`diagnostic listener failed: ${error}`);
4128
+ });
4129
+
4130
+ for (const eventName of ['keydown', 'keyup', 'compositionstart', 'compositionupdate', 'compositionend']) {
4131
+ window.addEventListener(eventName, (event) => {
4132
+ const message =
4133
+ eventName.startsWith('composition')
4134
+ ? `renderer ${eventName} data=${event.data}`
4135
+ : `renderer ${eventName} key=${event.key} code=${event.code} ctrl=${event.ctrlKey} alt=${event.altKey} shift=${event.shiftKey} meta=${event.metaKey} composing=${event.isComposing}`;
4136
+ showDiagnostic(message);
4137
+ });
4138
+ }
4139
+
4140
+ window.addEventListener('focus', focusTerminalInput);
4141
+ terminalElement.addEventListener('pointerdown', () => {
4142
+ setTimeout(focusTerminalInput, 0);
4143
+ });
4144
+
4145
+ await loadPlugins();
4146
+ installTerminalPasteHandlers();
4147
+ await afterNextPaint();
4148
+ fitAddon.fit();
4149
+ try {
4150
+ await window.fpasoterm.startTerminal(terminalPtySize());
4151
+ await refreshTerminalLogControl();
4152
+ await afterNextPaint();
4153
+ fitAndResize();
4154
+ } catch (error) {
4155
+ showTerminalError(`failed to start terminal backend: ${error.stack || error.message || error}`);
4156
+ return;
4157
+ }
4158
+ installCompositionObserver();
4159
+ installImeVisualFallback();
4160
+ // A no-output fallback keeps lifecycle plugins usable for shells that do not
4161
+ // print a prompt. Normal PTY output resets this timer until it is painted.
4162
+ schedulePluginsReadyAfterTerminalOutput(350);
4163
+ focusTerminalInput();
4164
+ }
4165
+
4166
+ initialize();