@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 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
+ [![npm version](https://img.shields.io/npm/v/@jacksontian/mwt)](https://www.npmjs.com/package/@jacksontian/mwt)
8
+ [![npm downloads](https://img.shields.io/npm/dm/@jacksontian/mwt)](https://www.npmjs.com/package/@jacksontian/mwt)
9
+ [![Node.js](https://img.shields.io/node/v/@jacksontian/mwt)](https://nodejs.org)
10
+ [![License](https://img.shields.io/npm/l/@jacksontian/mwt)](LICENSE)
11
+
12
+ ### Side by Side
13
+
14
+ ![Side by Side](./figures/sidebyside.png)
15
+
16
+ ### Grid
17
+
18
+ ![Grid](./figures/grid.png)
19
+
20
+ ### Tabs
21
+
22
+ ![Tabs](./figures/tabs.png)
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
+ }