@jacksontian/mwt 1.0.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.
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/bin/mwt.js +33 -0
- package/lib/ring-buffer.js +53 -0
- package/lib/server.js +212 -0
- package/package.json +41 -0
- package/public/css/style.css +579 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +56 -0
- package/public/js/app.js +278 -0
- package/public/js/layout-manager.js +160 -0
- package/public/js/terminal-manager.js +353 -0
- package/public/js/theme-manager.js +96 -0
- package/public/js/ws-client.js +136 -0
- package/public/vendor/xterm/addon-fit.mjs +18 -0
- package/public/vendor/xterm/addon-web-links.mjs +18 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jackson Tian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# mwt - Multi-Window Terminal
|
|
2
|
+
|
|
3
|
+
A lightweight web-based multi-window terminal for **local development**. Run multiple shell sessions in the browser with side-by-side, grid, and tab layouts.
|
|
4
|
+
|
|
5
|
+
> **Note:** mwt is designed for local use on your development machine. It does not provide authentication, encryption, or any access control — do not expose it to the network.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@jacksontian/mwt)
|
|
8
|
+
[](https://www.npmjs.com/package/@jacksontian/mwt)
|
|
9
|
+
[](https://nodejs.org)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
### Side by Side
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
### Grid
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
### Tabs
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
## Why mwt?
|
|
25
|
+
|
|
26
|
+
### 设计动机
|
|
27
|
+
|
|
28
|
+
日常开发中,我们经常需要同时运行多个终端会话:一个跑开发服务器,一个跑测试,一个执行 git 操作,还有一个用来查看日志。现有的方案要么太重,要么上手成本不低:
|
|
29
|
+
|
|
30
|
+
- **tmux / screen** — 功能强大,但快捷键体系需要专门学习,配置门槛较高,对新手不够友好
|
|
31
|
+
- **VS Code 内置终端** — 使用方便,但你不得不启动一个完整的 IDE,仅仅为了开几个终端窗口
|
|
32
|
+
- **iTerm2 / Windows Terminal** — 依赖特定操作系统,无法跨平台统一体验
|
|
33
|
+
- **ttyd / Wetty / code-server** — 面向远程场景设计,功能和配置远超本地开发所需
|
|
34
|
+
|
|
35
|
+
mwt 想解决的问题很简单:**给本地开发提供一个轻量、直觉化、开箱即用的多窗口终端**。
|
|
36
|
+
|
|
37
|
+
在 AI 时代,这个需求变得更加迫切。真正发挥生产力的方式不是盯着一个 AI 等它输出,而是**同时指挥多个 AI 工具并行干活**——一个窗口让 Claude Code 重构后端,一个窗口让另一个 Agent 写测试,一个窗口盯着构建日志,一个窗口查文档。mwt 天然适合这种「一人指挥,多路并发」的工作模式:轻量启动、多窗口并排、一眼掌控全局。
|
|
38
|
+
|
|
39
|
+
### 设计原则
|
|
40
|
+
|
|
41
|
+
**极简依赖** — 后端仅依赖 node-pty 和 ws 两个包,前端使用原生 ES Modules + xterm.js,没有框架、没有构建工具、没有打包步骤。`npx @jacksontian/mwt` 一行命令即可启动。
|
|
42
|
+
|
|
43
|
+
**浏览器即界面** — 选择浏览器作为 UI 层,天然跨平台,不需要安装桌面应用。布局切换、主题跟随、快捷键操作都在浏览器中完成,所见即所得。
|
|
44
|
+
|
|
45
|
+
**会话不丢失** — 每个终端保留 100KB 的输出缓冲区,刷新页面或网络断开后可以恢复现场。断线 30 分钟内自动重连,开发流程不被打断。
|
|
46
|
+
|
|
47
|
+
**够用就好** — 不做 SSH、不做认证、不做插件系统。mwt 只专注于一件事:在本地高效地管理多个终端窗口。功能边界清晰,代码量控制在 2000 行左右,任何人都可以快速理解和修改。
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- **Multi-terminal** - Create and manage multiple terminal sessions simultaneously
|
|
52
|
+
- **Three layouts** - Side-by-side, grid, and tabs, switch anytime
|
|
53
|
+
- **Session persistence** - Reconnect without losing terminal output (100KB buffer per terminal)
|
|
54
|
+
- **Auto-reconnect** - WebSocket disconnection recovery with exponential backoff
|
|
55
|
+
- **Dark / Light theme** - Manual toggle or follow system preference
|
|
56
|
+
- **Keyboard-driven** - Full keyboard shortcut support
|
|
57
|
+
- **Zero build step** - No webpack, no bundler, just run
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx @jacksontian/mwt
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or install globally:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install -g @jacksontian/mwt
|
|
69
|
+
mwt
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Then open http://localhost:1987 in your browser.
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
mwt [options]
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
-p, --port <port> Port to listen on (default: 1987)
|
|
81
|
+
-h, --help Show this help message
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
mwt -p 8080
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Keyboard Shortcuts
|
|
91
|
+
|
|
92
|
+
| Shortcut | Action |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `Ctrl+Shift+T` | New terminal |
|
|
95
|
+
| `Ctrl+Shift+W` | Close active terminal |
|
|
96
|
+
| `Ctrl+Shift+]` | Next terminal |
|
|
97
|
+
| `Ctrl+Shift+[` | Previous terminal |
|
|
98
|
+
| `Alt+1-9` | Switch to terminal N |
|
|
99
|
+
| `Ctrl+Shift+M` | Maximize / restore active terminal |
|
|
100
|
+
| `F11` | Toggle fullscreen |
|
|
101
|
+
|
|
102
|
+
## Architecture
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
Browser Server (Node.js)
|
|
106
|
+
┌──────────────┐ WebSocket ┌──────────────┐
|
|
107
|
+
│ xterm.js │◄──────────────►│ node-pty │
|
|
108
|
+
│ App.js │ │ RingBuffer │
|
|
109
|
+
│ LayoutMgr │ │ Session Mgr │
|
|
110
|
+
│ ThemeMgr │ └──────────────┘
|
|
111
|
+
└──────────────┘
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- **Frontend** - Vanilla JS + xterm.js, no framework
|
|
115
|
+
- **Backend** - Node.js HTTP server + WebSocket (ws)
|
|
116
|
+
- **PTY** - node-pty spawns real shell processes
|
|
117
|
+
- **Buffer** - RingBuffer keeps last 100KB output per terminal for session restore
|
|
118
|
+
|
|
119
|
+
## Dependencies
|
|
120
|
+
|
|
121
|
+
Only two runtime backend dependencies:
|
|
122
|
+
|
|
123
|
+
- [node-pty](https://github.com/microsoft/node-pty) - Pseudo-terminal management
|
|
124
|
+
- [ws](https://github.com/websockets/ws) - WebSocket server
|
|
125
|
+
|
|
126
|
+
Frontend uses [xterm.js](https://xtermjs.org/) with fit and web-links addons, served from node_modules.
|
|
127
|
+
|
|
128
|
+
## Comparison
|
|
129
|
+
|
|
130
|
+
| | mwt | tmux / screen | iTerm2 / Windows Terminal | VS Code 终端 | ttyd | Wetty | code-server |
|
|
131
|
+
|---|---|---|---|---|---|---|---|
|
|
132
|
+
| **多终端管理** | ✅ 并排 / 网格 / 标签页 | ✅ 窗格 / 窗口 | ✅ 标签页 / 分屏 | ✅ 分屏 / 标签页 | ❌ 单终端 | ❌ 单终端 | ✅ 内嵌终端 |
|
|
133
|
+
| **运行环境** | 浏览器 | 终端 | macOS 专属 | 桌面应用 | 浏览器 | 浏览器 | 浏览器 |
|
|
134
|
+
| **跨平台** | ✅ 任意有浏览器的系统 | ✅ Unix/Linux/macOS | ❌ macOS only | ✅ | ✅ | ✅ | ✅ |
|
|
135
|
+
| **上手成本** | 零配置,开箱即用 | 高,需学习快捷键体系 | 低 | 低(需安装 IDE) | 低 | 中等 | 中等 |
|
|
136
|
+
| **安装体积** | ~2MB(2 个运行时依赖) | 系统包管理器安装 | ~30MB | ~300MB+ | ~1MB(C 编译) | ~50MB | ~200MB+ |
|
|
137
|
+
| **构建步骤** | 无 | 无 | N/A | N/A | 需编译 C | 需 npm install | 需编译 |
|
|
138
|
+
| **会话持久化** | ✅ 100KB 缓冲 + 自动重连 | ✅ detach/attach | ❌ | ❌ | ❌ | ❌ | ❌ |
|
|
139
|
+
| **布局切换** | ✅ 三种布局一键切换 | ✅ 手动分屏 | ✅ | ✅ | ❌ | ❌ | ✅ |
|
|
140
|
+
| **主题切换** | ✅ 深色/浅色 + 跟随系统 | 需手动配置 | ✅ | ✅ | ❌ | ❌ | ✅ |
|
|
141
|
+
| **远程/SSH** | ❌ 仅本地 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
142
|
+
| **认证/权限** | ❌ 不需要 | N/A | N/A | N/A | 可选 | ✅ | ✅ |
|
|
143
|
+
| **适用场景** | 本地开发多终端 | 服务器运维/本地开发 | macOS 日常使用 | 编码 + 终端一体化 | 远程单终端 | 远程终端 | 远程开发 |
|
|
144
|
+
|
|
145
|
+
**mwt 的定位**:如果你只是想在本地开发时开几个终端窗口,不想为此启动一个 IDE,也不想花时间学 tmux,mwt 是最轻量的选择。它不试图替代任何一个竞品,而是填补了「本地轻量多终端」这个细分场景的空白。
|
|
146
|
+
|
|
147
|
+
## Limitations
|
|
148
|
+
|
|
149
|
+
mwt is intentionally simple. It does **not** support:
|
|
150
|
+
|
|
151
|
+
- SSH or remote server connections — local shell only
|
|
152
|
+
- Authentication / HTTPS — not needed for localhost
|
|
153
|
+
- File editing — use your own editor
|
|
154
|
+
- Plugin system — keep it minimal
|
|
155
|
+
|
|
156
|
+
If you need remote access or multi-user support, consider [ttyd](https://github.com/tsl0922/ttyd), [Wetty](https://github.com/butlerx/wetty), or [code-server](https://github.com/coder/code-server).
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
package/bin/mwt.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
|
|
5
|
+
const { values } = parseArgs({
|
|
6
|
+
options: {
|
|
7
|
+
port: { type: 'string', short: 'p', default: '1987' },
|
|
8
|
+
host: { type: 'string', default: '127.0.0.1' },
|
|
9
|
+
open: { type: 'boolean', default: true },
|
|
10
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
11
|
+
},
|
|
12
|
+
allowNegative: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (values.help) {
|
|
16
|
+
console.log(`
|
|
17
|
+
mwt - Multi-Window Terminal
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
mwt [options]
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
-p, --port <port> Port to listen on (default: 1987)
|
|
24
|
+
--host <host> Host to bind to (default: 127.0.0.1)
|
|
25
|
+
--no-open Do not open the browser automatically
|
|
26
|
+
-h, --help Show this help message
|
|
27
|
+
`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
import {start} from '../lib/server.js';
|
|
32
|
+
|
|
33
|
+
start(Number(values.port), values.host, { open: values.open });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export class RingBuffer {
|
|
2
|
+
constructor(capacity = 100 * 1024) {
|
|
3
|
+
this.capacity = capacity;
|
|
4
|
+
this.buffer = Buffer.alloc(capacity);
|
|
5
|
+
this.length = 0; // bytes currently stored
|
|
6
|
+
this.offset = 0; // next write position (wraps around)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
write(chunk) {
|
|
10
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
11
|
+
|
|
12
|
+
if (buf.length >= this.capacity) {
|
|
13
|
+
// Chunk alone fills the entire buffer — keep only the tail
|
|
14
|
+
buf.copy(this.buffer, 0, buf.length - this.capacity);
|
|
15
|
+
this.offset = 0;
|
|
16
|
+
this.length = this.capacity;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const spaceAtEnd = this.capacity - this.offset;
|
|
21
|
+
|
|
22
|
+
if (buf.length <= spaceAtEnd) {
|
|
23
|
+
buf.copy(this.buffer, this.offset);
|
|
24
|
+
} else {
|
|
25
|
+
// Wrap around
|
|
26
|
+
buf.copy(this.buffer, this.offset, 0, spaceAtEnd);
|
|
27
|
+
buf.copy(this.buffer, 0, spaceAtEnd);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.offset = (this.offset + buf.length) % this.capacity;
|
|
31
|
+
this.length = Math.min(this.length + buf.length, this.capacity);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
read() {
|
|
35
|
+
if (this.length === 0) return '';
|
|
36
|
+
|
|
37
|
+
if (this.length < this.capacity) {
|
|
38
|
+
// Haven't wrapped yet — data starts at 0
|
|
39
|
+
return this.buffer.toString('utf8', 0, this.length);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Buffer is full — data starts at this.offset (oldest byte)
|
|
43
|
+
const start = this.offset; // oldest byte position
|
|
44
|
+
const head = this.buffer.subarray(start, this.capacity);
|
|
45
|
+
const tail = this.buffer.subarray(0, start);
|
|
46
|
+
return Buffer.concat([head, tail]).toString('utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
clear() {
|
|
50
|
+
this.length = 0;
|
|
51
|
+
this.offset = 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { exec } from 'node:child_process';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
7
|
+
import pty from 'node-pty';
|
|
8
|
+
import { RingBuffer } from './ring-buffer.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
|
|
12
|
+
const MIME_TYPES = {
|
|
13
|
+
'.html': 'text/html',
|
|
14
|
+
'.css': 'text/css',
|
|
15
|
+
'.js': 'text/javascript',
|
|
16
|
+
'.mjs': 'text/javascript',
|
|
17
|
+
'.map': 'application/json',
|
|
18
|
+
'.json': 'application/json',
|
|
19
|
+
'.svg': 'image/svg+xml',
|
|
20
|
+
'.png': 'image/png',
|
|
21
|
+
'.ico': 'image/x-icon',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
25
|
+
const CLEANUP_INTERVAL_MS = 60 * 1000; // check every minute
|
|
26
|
+
const OUTPUT_BUFFER_SIZE = 100 * 1024; // 100KB per terminal
|
|
27
|
+
|
|
28
|
+
function openBrowser(url) {
|
|
29
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
30
|
+
: process.platform === 'win32' ? 'start'
|
|
31
|
+
: 'xdg-open';
|
|
32
|
+
exec(`${cmd} ${url}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function start(port = 1987, host = '127.0.0.1', options = {}) {
|
|
36
|
+
|
|
37
|
+
const startCwd = process.cwd();
|
|
38
|
+
const sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
|
|
39
|
+
|
|
40
|
+
// HTTP server for static files
|
|
41
|
+
const server = createServer(async (req, res) => {
|
|
42
|
+
let filePath;
|
|
43
|
+
const url = req.url.split('?')[0];
|
|
44
|
+
|
|
45
|
+
if (url === '/') {
|
|
46
|
+
filePath = join(__dirname, 'public', 'index.html');
|
|
47
|
+
} else {
|
|
48
|
+
filePath = join(__dirname, 'public', url);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const data = await readFile(filePath);
|
|
53
|
+
const ext = extname(filePath);
|
|
54
|
+
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
|
55
|
+
res.end(data);
|
|
56
|
+
} catch {
|
|
57
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
58
|
+
res.end('Not Found');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// WebSocket server
|
|
63
|
+
const wss = new WebSocketServer({ server });
|
|
64
|
+
|
|
65
|
+
wss.on('connection', (ws, req) => {
|
|
66
|
+
const url = new URL(req.url, 'http://localhost');
|
|
67
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
68
|
+
if (!sessionId) {
|
|
69
|
+
ws.close(4001, 'Missing sessionId');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let session = sessions.get(sessionId);
|
|
74
|
+
if (!session) {
|
|
75
|
+
session = { terminals: new Map(), ws: null, disconnectedAt: null };
|
|
76
|
+
sessions.set(sessionId, session);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Detach old WS if still open
|
|
80
|
+
if (session.ws && session.ws !== ws && session.ws.readyState === 1) {
|
|
81
|
+
session.ws.close();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
session.ws = ws;
|
|
85
|
+
session.disconnectedAt = null;
|
|
86
|
+
|
|
87
|
+
// Send session-restore with existing terminal IDs
|
|
88
|
+
const terminalIds = [...session.terminals.keys()];
|
|
89
|
+
ws.send(JSON.stringify({ type: 'session-restore', terminals: terminalIds }));
|
|
90
|
+
|
|
91
|
+
// Send buffered output for each existing terminal
|
|
92
|
+
for (const [id, entry] of session.terminals) {
|
|
93
|
+
const buffered = entry.outputBuffer.read();
|
|
94
|
+
if (buffered) {
|
|
95
|
+
ws.send(JSON.stringify({ type: 'buffer', id, data: buffered }));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Signal that all buffer messages have been sent
|
|
100
|
+
if (terminalIds.length > 0) {
|
|
101
|
+
ws.send(JSON.stringify({ type: 'restore-complete' }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ws.on('message', async (raw) => {
|
|
105
|
+
let msg;
|
|
106
|
+
try {
|
|
107
|
+
msg = JSON.parse(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
switch (msg.type) {
|
|
113
|
+
case 'create': {
|
|
114
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
115
|
+
let ptyProcess;
|
|
116
|
+
try {
|
|
117
|
+
ptyProcess = pty.spawn(shell, [], {
|
|
118
|
+
name: 'xterm-256color',
|
|
119
|
+
cols: msg.cols || 80,
|
|
120
|
+
rows: msg.rows || 24,
|
|
121
|
+
cwd: startCwd,
|
|
122
|
+
env: process.env,
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`Failed to spawn shell "${shell}":`, err.message);
|
|
126
|
+
if (ws.readyState === 1) {
|
|
127
|
+
ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode: 1 }));
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const outputBuffer = new RingBuffer(OUTPUT_BUFFER_SIZE);
|
|
133
|
+
session.terminals.set(msg.id, { pty: ptyProcess, outputBuffer });
|
|
134
|
+
|
|
135
|
+
ptyProcess.onData((data) => {
|
|
136
|
+
outputBuffer.write(data);
|
|
137
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
138
|
+
session.ws.send(JSON.stringify({ type: 'data', id: msg.id, data }));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
143
|
+
session.terminals.delete(msg.id);
|
|
144
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
145
|
+
session.ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode }));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case 'data': {
|
|
152
|
+
const entry = session.terminals.get(msg.id);
|
|
153
|
+
if (entry) entry.pty.write(msg.data);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case 'resize': {
|
|
158
|
+
const entry = session.terminals.get(msg.id);
|
|
159
|
+
if (entry) {
|
|
160
|
+
try {
|
|
161
|
+
entry.pty.resize(msg.cols, msg.rows);
|
|
162
|
+
} catch {
|
|
163
|
+
// ignore invalid resize
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case 'close': {
|
|
170
|
+
const entry = session.terminals.get(msg.id);
|
|
171
|
+
if (entry) {
|
|
172
|
+
entry.pty.kill();
|
|
173
|
+
session.terminals.delete(msg.id);
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
ws.on('close', () => {
|
|
182
|
+
if (session.ws === ws) {
|
|
183
|
+
session.ws = null;
|
|
184
|
+
session.disconnectedAt = Date.now();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
server.listen(port, host, () => {
|
|
190
|
+
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
191
|
+
console.log(`mwt running at ${url}`);
|
|
192
|
+
if (options.open !== false) {
|
|
193
|
+
openBrowser(url);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Clean up sessions that have been disconnected too long
|
|
198
|
+
const cleanupTimer = setInterval(() => {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
for (const [sessionId, session] of sessions) {
|
|
201
|
+
if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
|
|
202
|
+
for (const [, entry] of session.terminals) {
|
|
203
|
+
entry.pty.kill();
|
|
204
|
+
}
|
|
205
|
+
session.terminals.clear();
|
|
206
|
+
sessions.delete(sessionId);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}, CLEANUP_INTERVAL_MS);
|
|
210
|
+
cleanupTimer.unref();
|
|
211
|
+
|
|
212
|
+
} // end start
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jacksontian/mwt",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multi-window terminal with side-by-side, grid, and tab layouts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mwt": "bin/mwt.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"public/"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"terminal",
|
|
16
|
+
"xterm",
|
|
17
|
+
"multi-window",
|
|
18
|
+
"web-terminal"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "mocha",
|
|
23
|
+
"cov": "c8 mocha",
|
|
24
|
+
"cov:report": "c8 report --reporter=text --reporter=html"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"node-pty": "^1.2.0-beta.12",
|
|
31
|
+
"ws": "^8.18.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"c8": "^11.0.0",
|
|
35
|
+
"mocha": "^11.7.5"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"registry": "https://registry.npmjs.org/",
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|