tabminal 1.1.0 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Tabminal Contributors
3
+ Copyright (c) 2025 Leask Wong
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,42 +1,49 @@
1
- # Tabminal
1
+ # `t>` Tabminal
2
2
 
3
- > **A modern, AI-native web terminal built for the cloud age.**
4
- > Seamlessly accessible from Desktop, iPad, and iPhone with a native-like experience.
3
+ > **The AI-Native Terminal for the Mobile Age.**
4
+ > Seamlessly code from your Desktop, iPad, or iPhone with an intelligent, persistent, and touch-optimized experience.
5
5
 
6
- ![Tabminal Banner](public/favicon.svg)
6
+ ![Tabminal Banner](public/favicon.svg)
7
7
 
8
- ## Key Features
8
+ ## 🌟 Why Tabminal?
9
9
 
10
- ### 🧠 AI-Native Integration
11
- Tabminal isn't just a terminal; it's an intelligent workspace paired with **Gemini 2.5 Flash**.
12
- * **Context-Aware**: The AI knows your **Current Working Directory**, **Environment Variables**, and **Recent Command History**. No need to copy-paste context.
13
- * **Command Hijack (`#`)**: Simply type `#` followed by your question (e.g., `# how to tar a folder`) to chat with the AI. The output streams in real-time with syntax highlighting.
14
- * **Auto-Fix**: If a shell command fails (non-zero exit code), Tabminal automatically analyzes the error log and suggests a fix.
15
- * **Audit Logging**: All AI interactions are logged and persisted for future context.
10
+ Tabminal bridges the gap between traditional CLI tools and modern AI capabilities, all while solving the UX challenges of coding on mobile devices.
11
+
12
+ ### 🧠 AI-Native Intelligence
13
+ Powered by **modern AI models** (via OpenRouter), Tabminal understands your context.
14
+ *(Defaults to **Gemini 2.5 Flash** for optimal speed/performance balance if not configured)*
15
+ * **Context-Aware Chat**: Type `# how do I...` to ask questions. The AI knows your **CWD**, **Environment**, and **Recent History**.
16
+ * **Auto-Fix**: Command failed? Tabminal automatically analyzes the exit code and error output to suggest fixes. No copy-pasting required.
17
+ * **Web Search**: Enable Google Search integration to let the AI fetch real-time answers from the web.
16
18
 
17
19
  ### 📱 Ultimate Mobile Experience
18
- Optimized specifically for **iPadOS** and **iOS**, solving the pain points of coding on touch devices.
19
- * **PWA Support**: Installable as a full-screen app. Solves the infamous iOS viewport height issues.
20
- * **HHKB-Style Soft Keyboard**: A custom 12-column virtual keyboard bringing the HHKB layout to iPhone.
21
- * **Drag-to-Ctrl**: Touch and drag the `CTRL` key to perform combinations (e.g., drag to 'C' for `Ctrl+C`).
22
- * **Smart Modifiers**: `SHIFT` allows continuous entry; `SYM` toggles the full keyboard overlay.
23
- * **Responsive Layout**: Keyboard height adapts to landscape/portrait modes automatically.
24
- * **Optimized UI**: Hamburger menu for sessions on small screens, resource-saving mode (no preview rendering) on iPhone.
20
+ Built from the ground up for **iPadOS** and **iOS**.
21
+ * **HHKB Virtual Keyboard**: A specialized software keyboard overlay with `CTRL`, `ALT`, `TAB`, and arrow keys.
22
+ * **Smart Modifiers**: Toggle `SHIFT` for continuous entry.
23
+ * **Responsive Layout**: Auto-adapts to landscape/portrait modes, respecting Safe Areas and Notches.
24
+ * **PWA Ready**: Install to Home Screen for a full-screen, native app feel.
25
25
 
26
26
  ### 💻 Powerful Desktop Features
27
- * **Persistent Sessions**: Close your browser, come back later, and your terminal state (and running processes) are exactly where you left them.
28
- * **Built-in Editor**: Integrated **Monaco Editor** (VS Code core) with split-pane view. Edit files on the server directly.
29
- * **Visual File Manager**: Sidebar file tree for easy navigation and opening of files.
30
- * **Network Heartbeat**: Real-time latency visualization (capsule style on desktop, bottom-fill on mobile).
27
+ * **Persistent Sessions**: Your terminal state lives on the server. Refresh or switch devices without losing your work.
28
+ * **Built-in Editor**: Integrated **Monaco Editor** (VS Code core) allows you to edit files directly on the server.
29
+ * **Visual File Manager**: Sidebar file tree for easy navigation.
30
+ * **Network Heartbeat**: Real-time latency visualization.
31
31
 
32
32
  ## 🚀 Getting Started
33
33
 
34
34
  ### Prerequisites
35
- * Node.js >= 16
36
- * An AI API Key (e.g., Google AI Studio / OpenRouter)
35
+ * Node.js >= 22
36
+ * (Optional) An [OpenRouter](https://openrouter.ai/) API Key if you want AI features.
37
+ * (Optional) A pair of Google API Key and Search Engine ID (CX) for web search capabilities.
38
+
39
+ ### ⚠️ Security Warning
40
+ Tabminal provides **full read/write access** to the underlying file system.
41
+ * **Do NOT expose this to the public internet** without proper protection (VPN, etc).
42
+ * The `--accept-terms` flag is required to acknowledge that you understand these risks.
37
43
 
38
44
  ### Quick Start (No Install)
39
- Run directly with npx:
45
+ Run directly with `npx`:
46
+
40
47
  ```bash
41
48
  npx tabminal --openrouter-key "YOUR_API_KEY" --accept-terms
42
49
  ```
@@ -45,7 +52,7 @@ npx tabminal --openrouter-key "YOUR_API_KEY" --accept-terms
45
52
 
46
53
  ```bash
47
54
  # Clone the repository
48
- git clone https://github.com/yourusername/tabminal.git
55
+ git clone https://github.com/leask/tabminal.git
49
56
  cd tabminal
50
57
 
51
58
  # Install dependencies
@@ -57,39 +64,38 @@ npm start -- --openrouter-key "YOUR_API_KEY" --accept-terms
57
64
 
58
65
  ### Configuration
59
66
 
60
- You can configure Tabminal via command-line arguments or environment variables.
67
+ You can configure Tabminal via command-line arguments, environment variables, or a `config.json` file.
61
68
 
62
69
  | Argument | Env Variable | Description | Default |
63
70
  | :--- | :--- | :--- | :--- |
64
71
  | `-p`, `--port` | `PORT` | Server port | `9846` |
65
72
  | `-h`, `--host` | `HOST` | Bind address | `127.0.0.1` |
66
73
  | `-a`, `--password` | `TABMINAL_PASSWORD` | Access password | (Randomly Generated) |
67
- | `-k`, `--openrouter-key` | `TABMINAL_OPENROUTER_KEY` | OpenRouter API Key | `null` |
74
+ | `-k`, `--openrouter-key` | `TABMINAL_OPENROUTER_KEY` | AI Provider API Key | `null` |
68
75
  | `-m`, `--model` | `TABMINAL_MODEL` | AI Model ID | `gemini-2.5-flash-preview-09-2025` |
69
76
  | `-g`, `--google-key` | `TABMINAL_GOOGLE_KEY` | Google Search API Key | `null` |
70
77
  | `-c`, `--google-cx` | `TABMINAL_GOOGLE_CX` | Google Search Engine ID (CX) | `null` |
71
78
  | `-d`, `--debug` | `TABMINAL_DEBUG` | Enable debug logs | `false` |
72
- | `-y`, `--accept-terms` | `TABMINAL_ACCEPT` | Accept security terms | `false` |
79
+ | `-y`, `--accept-terms` | `TABMINAL_ACCEPT` | **Required**: Accept security risks (Full FS Access) | `false` |
73
80
 
74
- ## ⌨️ Shortcuts
81
+ ## ⌨️ Shortcuts & Gestures
75
82
 
76
- ### Physical Keyboard (Desktop/iPad)
77
- * **`Ctrl + Shift + T`**: New Tab (Inherits current CWD)
78
- * **`Ctrl + Shift + W`**: Close Current Tab
79
- * **`Ctrl + Shift + [` / `]`**: Switch Previous/Next Tab
80
- * **`Ctrl + Alt + [` / `]`**: Switch Previous/Next Open File in Editor
83
+ * **`Ctrl + Shift + T`**: New Terminal
84
+ * **`Ctrl + Shift + W`**: Close Terminal
81
85
  * **`Ctrl + Shift + E`**: Toggle Editor Pane
86
+ * **`Ctrl + Up` / `Down`**: Focus Editor / Terminal
87
+ * **`Ctrl + Shift + [` / `]`**: Switch Terminal
88
+ * **`Ctrl + Alt + [` / `]`**: Switch Open File in Editor
82
89
  * **`Ctrl + Shift + ?`**: Show Shortcuts Help
90
+ * **`Ctrl` / `Cmd` + `F`**: Find in Terminal
83
91
 
84
- ### Touch Gestures (Mobile)
85
- * **Virtual `^C`**: Send SIGINT (Ctrl+C).
86
- * **Virtual `CTRL` (Hold & Drag)**: Visualize a QWERTY overlay to quickly trigger Control combinations without lifting your finger.
87
- * **Virtual `SYM`**: Toggle the full HHKB-style soft keyboard.
92
+ ### Touch Actions
93
+ * **Virtual `SYM`**: Toggle HHKB keyboard overlay.
88
94
 
89
95
  ## 🛠 Tech Stack
90
- * **Backend**: Node.js, Koa, node-pty, WebSocket (ws).
91
- * **Frontend**: Vanilla JS (ES Modules), xterm.js, Monaco Editor.
92
- * **AI**: Integration via `utilitas`.
96
+ * **Backend**: [Node.js](https://nodejs.org), [Koa](https://github.com/koajs/koa), [node-pty](https://github.com/microsoft/node-pty), [WebSocket](https://github.com/websockets/ws).
97
+ * **Frontend**: [Vanilla JS](http://vanilla-js.com/) 😝, [xterm.js](https://github.com/xtermjs/xterm.js), [Monaco Editor](https://github.com/microsoft/monaco-editor).
98
+ * **AI**: Integration via [utilitas](https://github.com/leask/utilitas).
93
99
 
94
100
  ## 📄 License
95
- MIT
101
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabminal",
3
- "version": "1.1.0",
3
+ "version": "1.1.5",
4
4
  "description": "A modern, persistent web terminal with multi-tab support and real-time system monitoring.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,12 +28,14 @@
28
28
  "author": "Leask Wong",
29
29
  "license": "MIT",
30
30
  "engines": {
31
- "node": ">=18.0.0"
31
+ "node": ">=22.0.0"
32
32
  },
33
33
  "dependencies": {
34
34
  "@fontsource/monaspace-neon": "^5.2.5",
35
35
  "@koa/router": "^14.0.0",
36
+ "@mozilla/readability": "^0.6.0",
36
37
  "js-tiktoken": "^1.0.21",
38
+ "jsdom": "^27.2.0",
37
39
  "koa": "^3.1.1",
38
40
  "koa-bodyparser": "^4.4.1",
39
41
  "koa-static": "^5.0.0",
package/public/app.js CHANGED
@@ -2,6 +2,7 @@ import { Terminal } from 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/+esm';
2
2
  import { FitAddon } from 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/+esm';
3
3
  import { WebLinksAddon } from 'https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/+esm';
4
4
  import { CanvasAddon } from 'https://cdn.jsdelivr.net/npm/xterm-addon-canvas@0.5.0/+esm';
5
+ import { SearchAddon } from 'https://cdn.jsdelivr.net/npm/xterm-addon-search@0.13.0/+esm';
5
6
 
6
7
  // Detect Mobile/Tablet (focus on touch capability for font sizing)
7
8
  // Logic: If the device supports touch, we assume it needs larger fonts (14px)
@@ -63,10 +64,101 @@ class AuthManager {
63
64
  }
64
65
 
65
66
  async hashPassword(password) {
66
- const msgBuffer = new TextEncoder().encode(password);
67
- const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
68
- const hashArray = Array.from(new Uint8Array(hashBuffer));
69
- return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
67
+ if (window.crypto && window.crypto.subtle) {
68
+ try {
69
+ const msgBuffer = new TextEncoder().encode(password);
70
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
71
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
72
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
73
+ } catch (e) {
74
+ console.warn('Web Crypto API failed, falling back to JS implementation', e);
75
+ }
76
+ }
77
+ return this.sha256Fallback(password);
78
+ }
79
+
80
+ sha256Fallback(ascii) {
81
+ function rightRotate(value, amount) {
82
+ return (value >>> amount) | (value << (32 - amount));
83
+ }
84
+
85
+ const mathPow = Math.pow;
86
+ const maxWord = mathPow(2, 32);
87
+ const lengthProperty = 'length';
88
+ let i, j;
89
+ let result = '';
90
+
91
+ const words = [];
92
+ const asciiBitLength = ascii[lengthProperty] * 8;
93
+
94
+ let hash = (this._h = this._h || [
95
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
96
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
97
+ ]).slice(0);
98
+
99
+ const k = (this._k = this._k || [
100
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
101
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
102
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
103
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
104
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
105
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
106
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
107
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
108
+ ]);
109
+
110
+ ascii += '\x80';
111
+ while (ascii[lengthProperty] % 64 - 56) ascii += '\x00';
112
+
113
+ for (i = 0; i < ascii[lengthProperty]; i++) {
114
+ j = ascii.charCodeAt(i);
115
+ words[i >> 2] |= j << ((3 - i) % 4) * 8;
116
+ }
117
+ words[words[lengthProperty]] = ((asciiBitLength / maxWord) | 0);
118
+ words[words[lengthProperty]] = (asciiBitLength);
119
+
120
+ for (j = 0; j < words[lengthProperty];) {
121
+ const w = words.slice(j, j += 16);
122
+ const oldHash = hash;
123
+
124
+ hash = hash.slice(0, 8);
125
+
126
+ for (i = 0; i < 64; i++) {
127
+ const i2 = i + j;
128
+ const w15 = w[i - 15], w2 = w[i - 2];
129
+
130
+ const a = hash[0], e = hash[4];
131
+ const temp1 = hash[7]
132
+ + (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)) // S1
133
+ + ((e & hash[5]) ^ ((~e) & hash[6])) // ch
134
+ + k[i]
135
+ + (w[i] = (i < 16) ? w[i] : (
136
+ w[i - 16]
137
+ + (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3)) // s0
138
+ + w[i - 7]
139
+ + (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10)) // s1
140
+ ) | 0
141
+ );
142
+
143
+ const temp2 = (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)) // S0
144
+ + ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2])); // maj
145
+
146
+ hash = [(temp1 + temp2) | 0].concat(hash);
147
+ hash[4] = (hash[4] + temp1) | 0;
148
+ }
149
+
150
+ for (i = 0; i < 8; i++) {
151
+ hash[i] = (hash[i] + oldHash[i]) | 0;
152
+ }
153
+ }
154
+
155
+ for (i = 0; i < 8; i++) {
156
+ for (j = 3; j + 1; j--) {
157
+ const b = (hash[i] >> (j * 8)) & 255;
158
+ result += ((b < 16) ? 0 : '') + b.toString(16);
159
+ }
160
+ }
161
+ return result;
70
162
  }
71
163
 
72
164
  async login(password) {
@@ -539,7 +631,6 @@ class EditorManager {
539
631
 
540
632
  this.renderEditorTabs();
541
633
  this.updateEditorPaneVisibility();
542
- this.currentSession.saveState();
543
634
 
544
635
  if (state.activeFilePath === filePath) {
545
636
  if (state.openFiles.length > 0) {
@@ -549,6 +640,9 @@ class EditorManager {
549
640
  this.showEmptyState();
550
641
  }
551
642
  }
643
+
644
+ // Save state AFTER updating activeFilePath
645
+ this.currentSession.saveState();
552
646
  }
553
647
 
554
648
  renderEditorTabs() {
@@ -730,8 +824,10 @@ class Session {
730
824
  });
731
825
  this.mainFitAddon = new FitAddon();
732
826
  this.mainLinksAddon = new WebLinksAddon();
827
+ this.searchAddon = new SearchAddon();
733
828
  this.mainTerm.loadAddon(this.mainFitAddon);
734
829
  this.mainTerm.loadAddon(this.mainLinksAddon);
830
+ this.mainTerm.loadAddon(this.searchAddon);
735
831
  this.mainTerm.loadAddon(new CanvasAddon());
736
832
 
737
833
  // Event Listeners
@@ -1796,6 +1892,11 @@ async function switchToSession(sessionId) {
1796
1892
  session.mainFitAddon.fit();
1797
1893
  session.mainTerm.focus();
1798
1894
 
1895
+ // Double check focus
1896
+ requestAnimationFrame(() => {
1897
+ session.mainTerm.focus();
1898
+ });
1899
+
1799
1900
  session.reportResize();
1800
1901
 
1801
1902
  // Sync editor state
@@ -1911,10 +2012,21 @@ async function initApp() {
1911
2012
  // If no sessions, create one
1912
2013
  if (state.sessions.size === 0) {
1913
2014
  await createNewSession();
2015
+ } else if (state.activeSessionId) {
2016
+ const session = state.sessions.get(state.activeSessionId);
2017
+ if (session) session.mainTerm.focus();
1914
2018
  }
2019
+
2020
+ // Force focus again after layout settles
2021
+ setTimeout(() => {
2022
+ if (state.activeSessionId) {
2023
+ const session = state.sessions.get(state.activeSessionId);
2024
+ if (session) session.mainTerm.focus();
2025
+ }
2026
+ }, 200);
1915
2027
  }
1916
2028
 
1917
- // Virtual Keyboard Logic
2029
+ // Start the app
1918
2030
  const virtualKeys = document.getElementById('virtual-keys');
1919
2031
 
1920
2032
  if (virtualKeys) {
@@ -2131,25 +2243,125 @@ if (modCtrl && modAlt && modShift && modSym && softKeyboard) {
2131
2243
  });
2132
2244
  }
2133
2245
 
2134
- // Keyboard Shortcuts
2135
- document.addEventListener('keydown', (e) => {
2136
- const key = e.key.toLowerCase();
2246
+ // Search Bar Logic
2247
+ const searchBar = document.getElementById('search-bar');
2248
+ const searchInput = document.getElementById('search-input');
2249
+ const searchNext = document.getElementById('search-next');
2250
+ const searchPrev = document.getElementById('search-prev');
2251
+ const searchClose = document.getElementById('search-close');
2252
+ const searchResults = document.getElementById('search-results');
2253
+ const searchCaseBtn = document.getElementById('search-case');
2254
+ const searchWordBtn = document.getElementById('search-word');
2255
+ const searchRegexBtn = document.getElementById('search-regex');
2256
+ const searchToggleBtn = document.getElementById('search-toggle-replace');
2257
+
2258
+ let searchOptions = {
2259
+ caseSensitive: false,
2260
+ wholeWord: false,
2261
+ regex: false
2262
+ };
2263
+
2264
+ if (searchBar) {
2265
+ const updateUI = (found) => {
2266
+ if (!found) {
2267
+ searchResults.textContent = 'No results';
2268
+ searchNext.disabled = true;
2269
+ searchPrev.disabled = true;
2270
+ } else {
2271
+ searchResults.textContent = 'Found';
2272
+ searchNext.disabled = false;
2273
+ searchPrev.disabled = false;
2274
+ }
2275
+ };
2276
+
2277
+ const doSearch = (forward = true) => {
2278
+ if (!state.activeSessionId || !state.sessions.has(state.activeSessionId)) return;
2279
+ const addon = state.sessions.get(state.activeSessionId).searchAddon;
2280
+ const term = searchInput.value;
2281
+
2282
+ let found = false;
2283
+ if (forward) found = addon.findNext(term, searchOptions);
2284
+ else found = addon.findPrevious(term, searchOptions);
2285
+
2286
+ updateUI(found);
2287
+ };
2288
+
2289
+ const toggleOption = (btn, key) => {
2290
+ searchOptions[key] = !searchOptions[key];
2291
+ btn.classList.toggle('active', searchOptions[key]);
2292
+ doSearch(true);
2293
+ };
2294
+
2295
+ if (searchCaseBtn) searchCaseBtn.onclick = () => toggleOption(searchCaseBtn, 'caseSensitive');
2296
+ if (searchWordBtn) searchWordBtn.onclick = () => toggleOption(searchWordBtn, 'wholeWord');
2297
+ if (searchRegexBtn) searchRegexBtn.onclick = () => toggleOption(searchRegexBtn, 'regex');
2298
+
2299
+ // Initial State
2300
+ searchNext.disabled = true;
2301
+ searchPrev.disabled = true;
2302
+
2303
+ searchInput.addEventListener('input', (e) => {
2304
+ if (!state.activeSessionId) return;
2305
+ const term = e.target.value;
2306
+ if (!term) {
2307
+ updateUI(false);
2308
+ searchResults.textContent = ''; // Empty when clear? Or No results? VS Code clears.
2309
+ // But user asked for "No results always".
2310
+ // My updateUI sets 'No results'.
2311
+ return;
2312
+ }
2313
+
2314
+ // Incremental search
2315
+ const found = state.sessions.get(state.activeSessionId).searchAddon.findNext(term, {
2316
+ incremental: true,
2317
+ ...searchOptions
2318
+ });
2319
+
2320
+ updateUI(found);
2321
+ });
2137
2322
 
2138
- // ESC: Close Help Modal
2139
- if (key === 'escape') {
2140
- const modal = document.getElementById('shortcuts-modal');
2141
- if (modal && modal.style.display === 'flex') {
2323
+ searchInput.addEventListener('keydown', (e) => {
2324
+ if (e.key === 'Enter') {
2142
2325
  e.preventDefault();
2143
- modal.style.display = 'none';
2144
- if (state.activeSessionId && state.sessions.has(state.activeSessionId)) {
2145
- state.sessions.get(state.activeSessionId).mainTerm.focus();
2146
- }
2326
+ doSearch(!e.shiftKey);
2327
+ }
2328
+ if (e.key === 'Escape') {
2329
+ e.preventDefault();
2330
+ searchBar.style.display = 'none';
2331
+ state.sessions.get(state.activeSessionId)?.mainTerm.focus();
2332
+ }
2333
+ });
2334
+
2335
+ searchNext.addEventListener('click', () => doSearch(true));
2336
+ searchPrev.addEventListener('click', () => doSearch(false));
2337
+
2338
+ searchClose.addEventListener('click', () => {
2339
+ searchBar.style.display = 'none';
2340
+ state.sessions.get(state.activeSessionId)?.mainTerm.focus();
2341
+ });
2342
+ }
2343
+
2344
+ // Keyboard Shortcuts
2345
+ document.addEventListener('keydown', (e) => {
2346
+ // Ctrl+F or Cmd+F for Search
2347
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') {
2348
+ // If editor has focus, let Monaco handle it
2349
+ if (editorManager && editorManager.editor && editorManager.editor.hasTextFocus()) {
2147
2350
  return;
2148
2351
  }
2352
+
2353
+ e.preventDefault();
2354
+ if (searchBar) {
2355
+ searchBar.style.display = 'flex';
2356
+ searchInput.focus();
2357
+ searchInput.select();
2358
+ }
2359
+ return;
2149
2360
  }
2150
2361
 
2151
- if (!e.ctrlKey) return; // Ctrl is mandatory for others
2362
+ if (!e.ctrlKey) return; // Ctrl is mandatory
2152
2363
 
2364
+ const key = e.key.toLowerCase();
2153
2365
  const code = e.code;
2154
2366
 
2155
2367
  // Ctrl + Shift Context
@@ -2178,21 +2390,19 @@ document.addEventListener('keydown', (e) => {
2178
2390
  }
2179
2391
  return;
2180
2392
  }
2181
-
2393
+
2182
2394
  // Ctrl + Shift + ?: Help
2183
2395
  if (key === '?' || (code === 'Slash' && e.shiftKey)) {
2184
2396
  e.preventDefault();
2185
2397
  const modal = document.getElementById('shortcuts-modal');
2186
2398
  if (modal) {
2187
2399
  modal.style.display = 'flex';
2188
- // Steal focus from terminal
2189
2400
  const closeBtn = modal.querySelector('button');
2190
2401
  if (closeBtn) closeBtn.focus();
2191
2402
 
2192
2403
  modal.onclick = (ev) => {
2193
2404
  if (ev.target === modal) {
2194
2405
  modal.style.display = 'none';
2195
- // Restore focus
2196
2406
  if (state.activeSessionId && state.sessions.has(state.activeSessionId)) {
2197
2407
  state.sessions.get(state.activeSessionId).mainTerm.focus();
2198
2408
  }
@@ -2218,6 +2428,24 @@ document.addEventListener('keydown', (e) => {
2218
2428
  }
2219
2429
  }
2220
2430
 
2431
+ // Ctrl Only Context (Focus Switching)
2432
+ if (!e.shiftKey && !e.altKey) {
2433
+ if (code === 'ArrowUp') {
2434
+ e.preventDefault();
2435
+ if (editorManager && editorManager.pane.style.display !== 'none') {
2436
+ editorManager.editor.focus();
2437
+ }
2438
+ return;
2439
+ }
2440
+ if (code === 'ArrowDown') {
2441
+ e.preventDefault();
2442
+ if (state.activeSessionId && state.sessions.has(state.activeSessionId)) {
2443
+ state.sessions.get(state.activeSessionId).mainTerm.focus();
2444
+ }
2445
+ return;
2446
+ }
2447
+ }
2448
+
2221
2449
  // Ctrl + Option (Alt) Context
2222
2450
  if (e.altKey && !e.shiftKey) {
2223
2451
  // Ctrl + Option + [ / ]: Switch Editor File
@@ -2240,6 +2468,7 @@ document.addEventListener('keydown', (e) => {
2240
2468
  }
2241
2469
  }, true); // Use capture phase to override editor/terminal
2242
2470
 
2471
+
2243
2472
  // Start the app
2244
2473
  initApp();
2245
2474
  // #endregion
package/public/index.html CHANGED
@@ -36,6 +36,17 @@
36
36
  document.body.scrollTop = 0;
37
37
  }
38
38
  document.documentElement.scrollTop = 0;
39
+
40
+ // Sync terminal padding state with editor visibility
41
+ const editorPane = document.getElementById('editor-pane');
42
+ const terminalWrapper = document.getElementById('terminal-wrapper');
43
+ if (editorPane && terminalWrapper) {
44
+ if (editorPane.style.display !== 'none') {
45
+ terminalWrapper.classList.remove('maximized');
46
+ } else {
47
+ terminalWrapper.classList.add('maximized');
48
+ }
49
+ }
39
50
  }
40
51
 
41
52
  if (window.visualViewport) {
@@ -104,7 +115,7 @@
104
115
  </script>
105
116
  <script>
106
117
  (function() {
107
- const t = Math.floor(Date.now() / 60000);
118
+ const t = Math.floor(Date.now() / 1000); // Seconds for dev
108
119
  const link = document.createElement('link');
109
120
  link.rel = 'stylesheet';
110
121
  link.href = `./styles.css?t=${t}`;
@@ -154,6 +165,20 @@
154
165
 
155
166
  <div id="terminal-wrapper" class="terminal-wrapper">
156
167
  <div id="terminal" role="application" aria-label="Interactive Terminal"></div>
168
+ <div id="search-bar" class="search-bar" style="display: none;">
169
+ <button id="search-toggle-replace" class="icon-btn codicon codicon-chevron-right" title="Toggle Replace"></button>
170
+ <div class="search-input-wrapper">
171
+ <input type="text" id="search-input" placeholder="Find">
172
+ <div class="search-options">
173
+ <button id="search-case" class="option-btn codicon codicon-case-sensitive" title="Match Case"></button>
174
+ <button id="search-word" class="option-btn codicon codicon-whole-word" title="Whole Word"></button>
175
+ <button id="search-regex" class="option-btn codicon codicon-regex" title="Use Regular Expression"></button>
176
+ </div>
177
+ </div>
178
+ <span id="search-results" class="search-results">No results</span>
179
+ <button id="search-prev" class="nav-btn codicon codicon-arrow-up" title="Previous Match"></button>
180
+ <button id="search-next" class="nav-btn codicon codicon-arrow-down" title="Next Match"></button> <button id="search-close" class="close-btn codicon codicon-close" title="Close"></button>
181
+ </div>
157
182
  </div>
158
183
  </main>
159
184
  </div>
@@ -198,6 +223,10 @@
198
223
  <div><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>E</kbd></div>
199
224
  <span>Toggle Editor</span>
200
225
  </li>
226
+ <li>
227
+ <div><kbd>Ctrl</kbd> + <kbd>↑</kbd> / <kbd>↓</kbd></div>
228
+ <span>Focus Editor / Terminal</span>
229
+ </li>
201
230
  <li>
202
231
  <div><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>[</kbd> / <kbd>]</kbd></div>
203
232
  <span>Switch Terminal</span>
@@ -216,7 +245,7 @@
216
245
  </div>
217
246
  <script>
218
247
  (function() {
219
- const t = Math.floor(Date.now() / 60000);
248
+ const t = Math.floor(Date.now() / 1000);
220
249
  const script = document.createElement('script');
221
250
  script.type = 'module';
222
251
  script.src = `./app.js?t=${t}`;