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 +2 -2
- package/README.md +49 -43
- package/package.json +4 -2
- package/public/app.js +250 -21
- package/public/index.html +31 -2
- package/public/styles.css +423 -127
- package/src/terminal-manager.mjs +1 -1
- package/icon-gen.html +0 -46
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2025
|
|
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
|
-
> **
|
|
4
|
-
> Seamlessly
|
|
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
|
-

|
|
6
|
+

|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## 🌟 Why Tabminal?
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
* **
|
|
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
|
-
|
|
19
|
-
* **
|
|
20
|
-
* **
|
|
21
|
-
|
|
22
|
-
|
|
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**:
|
|
28
|
-
* **Built-in Editor**: Integrated **Monaco Editor** (VS Code core)
|
|
29
|
-
* **Visual File Manager**: Sidebar file tree for easy navigation
|
|
30
|
-
* **Network Heartbeat**: Real-time latency visualization
|
|
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 >=
|
|
36
|
-
* An
|
|
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/
|
|
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
|
|
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` |
|
|
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
|
|
79
|
+
| `-y`, `--accept-terms` | `TABMINAL_ACCEPT` | **Required**: Accept security risks (Full FS Access) | `false` |
|
|
73
80
|
|
|
74
|
-
## ⌨️ Shortcuts
|
|
81
|
+
## ⌨️ Shortcuts & Gestures
|
|
75
82
|
|
|
76
|
-
|
|
77
|
-
* **`Ctrl + Shift +
|
|
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
|
|
85
|
-
* **Virtual
|
|
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
|
|
91
|
-
* **Frontend**: Vanilla JS
|
|
92
|
-
* **AI**: Integration via
|
|
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.
|
|
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": ">=
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
2135
|
-
document.
|
|
2136
|
-
|
|
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
|
-
|
|
2139
|
-
|
|
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
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
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
|
|
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() /
|
|
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() /
|
|
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}`;
|