zerogterm 0.3.0-alpha.1 → 0.5.0-alpha.1
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/README.md +144 -29
- package/dist/main/main/clipboard.js +30 -0
- package/dist/main/main/main.js +19 -4
- package/dist/main/main/preload.cjs +1 -1
- package/dist/main/main/session-service.js +80 -49
- package/dist/main/main/shell-catalog.js +193 -0
- package/dist/renderer/assets/index-BJsoL2QL.js +17 -0
- package/dist/renderer/assets/index-BPfYUrvm.css +1 -0
- package/dist/renderer/assets/voice-worker-DoNT0zEM.js +37 -0
- package/dist/renderer/index.html +3 -3
- package/package.json +1 -1
- package/dist/renderer/assets/index-5pgRaxBt.css +0 -1
- package/dist/renderer/assets/index-Cs9gfbAs.js +0 -17
- package/dist/renderer/assets/voice-worker-DWb1dVxY.js +0 -37
package/README.md
CHANGED
|
@@ -1,21 +1,37 @@
|
|
|
1
1
|
# ZeroG Terminal
|
|
2
2
|
|
|
3
|
-
ZeroG Terminal is
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
3
|
+
ZeroG Terminal is an Electron workspace manager for persistent terminal and SSH sessions on Linux and Windows, with smart features to help keep you productive. It is designed with AI tooling in mind: leave Codex, Claude Code, or other terminal-based agents running in persistent sessions, then return to them without repeating setup and resume commands.
|
|
4
|
+
|
|
5
|
+
ZeroG Terminal is an alpha project, but it is already useful as a multi-session terminal and SSH workspace. It uses `screen` where available for resumable sessions and falls back to a direct shell PTY when `screen` is not installed.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Multi-pane workspaces with stack, vertical split, horizontal split, and four-pane grid layouts.
|
|
10
|
+
- Draggable dividers between panes and beside the sidebar, so a split does not have to be an even one. Sizes are remembered between launches.
|
|
11
|
+
- Maximize a focused pane and cycle between sessions without losing the other panes.
|
|
12
|
+
- Local sessions powered by Bash, PowerShell, WSL, and other supported shell backends; persistent sessions use `screen` where available, with a process-only fallback when it is unavailable.
|
|
13
|
+
- SSH sessions for hosts, `user@host`, and `user@host:port` targets.
|
|
14
|
+
- SSH configuration discovery from known connections, including remote `screen` session discovery.
|
|
15
|
+
- Reconnect to existing local or remote `screen` sessions from the Screens view.
|
|
16
|
+
- Remote screen attachment that waits for SSH readiness before sending commands, including host and port-aware matching.
|
|
17
|
+
- Session history for reconnecting to sessions after a relaunch, with bounded structured history and no stored secrets.
|
|
18
|
+
- Workspaces for grouping sessions and quickly switching between projects or tasks.
|
|
19
|
+
- Session overview, collapsible sidebar, keyboard shortcuts, and light/dark themes.
|
|
20
|
+
- xterm.js terminal rendering with scrollback preservation while changing layouts.
|
|
21
|
+
- Local voice input, either with Whisper ONNX inside the app through Transformers.js or through a transcription server on this machine; transcribed text is typed into the selected terminal without automatic execution.
|
|
22
|
+
- A per-pane proceed button that sends a configurable phrase — `OK, proceed` by default — for waving an agent on without typing the same reply again.
|
|
23
|
+
- A settings panel for appearance, terminal behaviour, session defaults, and speech recognition, including a built-in recognition test.
|
|
24
|
+
- AI command suggestion and approval UI, keeping command execution explicit.
|
|
25
|
+
- Sandboxed Electron renderer, context isolation, disabled Node integration, and a narrow typed preload API.
|
|
26
|
+
- Safe argument-array handling and validation around SSH and `screen` session operations.
|
|
27
|
+
|
|
28
|
+
The project is particularly useful for terminal-based AI development workflows: start an agent in a persistent session, disconnect or suffer an interrupted connection, and reconnect later to see what it has done and continue working.
|
|
29
|
+
|
|
30
|
+
See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
|
|
15
31
|
|
|
16
32
|
## Release status
|
|
17
33
|
|
|
18
|
-
ZeroG Terminal is currently a public alpha. The
|
|
34
|
+
ZeroG Terminal is currently a public alpha. The current release is `0.5.0-alpha.1`; the version history is tracked in [versions.txt](versions.txt).
|
|
19
35
|
|
|
20
36
|
The npm package contains the built Electron application and project documentation. It is intended for early adopters and testing rather than production use.
|
|
21
37
|
|
|
@@ -28,8 +44,98 @@ The npm package contains the built Electron application and project documentatio
|
|
|
28
44
|
- `Ctrl+Shift+T` — new local terminal in the current workspace
|
|
29
45
|
- `Ctrl+Shift+O` — session overview
|
|
30
46
|
- `Ctrl+Shift+B` — toggle sessions sidebar
|
|
47
|
+
- `Ctrl+Shift+,` — settings
|
|
31
48
|
- `Esc` — close overview / dialogs, cancel voice recording
|
|
32
49
|
|
|
50
|
+
Selecting text with the mouse also copies it, and programs running inside a
|
|
51
|
+
terminal can copy to the system clipboard themselves through the OSC 52 escape
|
|
52
|
+
sequence — this is how TUI tools such as CLI coding agents, tmux and Neovim put
|
|
53
|
+
text on the clipboard, including over SSH. Reading the clipboard through OSC 52
|
|
54
|
+
is refused, so a program on a remote host cannot see what you last copied.
|
|
55
|
+
|
|
56
|
+
## Resizing panes and the sidebar
|
|
57
|
+
|
|
58
|
+
Drag the line between two panes, or the sidebar's right edge, to change how the
|
|
59
|
+
space is shared. Sizes are remembered between launches and clamped so that no
|
|
60
|
+
pane can be dragged down to nothing.
|
|
61
|
+
|
|
62
|
+
The dividers take keyboard focus as well: the arrow keys nudge one two percent at
|
|
63
|
+
a time, and Enter or a double-click puts it back in the middle. One divider
|
|
64
|
+
position is shared by every layout, so a split you set up in the vertical split
|
|
65
|
+
is the same split you get in the four-pane grid.
|
|
66
|
+
|
|
67
|
+
## Settings
|
|
68
|
+
|
|
69
|
+
Settings open from the gear at the bottom of the left rail, the avatar in the
|
|
70
|
+
title bar, or `Ctrl+Shift+,`. Changes apply immediately and are remembered
|
|
71
|
+
between launches; each page can be reset on its own.
|
|
72
|
+
|
|
73
|
+
- **Appearance** — theme, terminal font, size, line height and letter spacing,
|
|
74
|
+
with a live preview. Panes restyle in place and keep their scrollback.
|
|
75
|
+
- **Terminal** — scrollback lines, cursor style and blink, and copy-on-select.
|
|
76
|
+
- **Sessions** — default shell and WSL distribution for new terminals, the
|
|
77
|
+
layout to start in, and whether the sidebar starts collapsed.
|
|
78
|
+
- **AI & voice** — whether AI suggestions need approval before running, and
|
|
79
|
+
whether a transcript is typed straight into the pane or shown for review
|
|
80
|
+
first. Neither option presses Enter for you. Also the phrase the pane's
|
|
81
|
+
proceed button sends, described below.
|
|
82
|
+
- **Speech recognition** — engine, model and tuning, described below.
|
|
83
|
+
|
|
84
|
+
### Proceed button
|
|
85
|
+
|
|
86
|
+
Each pane's title bar carries a tick beside the microphone. Clicking it sends
|
|
87
|
+
`OK, proceed` and presses Enter — for the common case of an AI coding agent
|
|
88
|
+
pausing to ask whether it should carry on. The phrase is editable under
|
|
89
|
+
**Settings ▸ AI & voice**, so an agent that responds better to different wording
|
|
90
|
+
can have it.
|
|
91
|
+
|
|
92
|
+
This is the one control that presses Enter for you; voice transcripts and AI
|
|
93
|
+
suggestions deliberately do not. It sends to the pane it belongs to, so a pane
|
|
94
|
+
sitting at a shell prompt rather than in an agent will simply try to run the
|
|
95
|
+
phrase as a command.
|
|
96
|
+
|
|
97
|
+
### Speech recognition
|
|
98
|
+
|
|
99
|
+
Two engines are available.
|
|
100
|
+
|
|
101
|
+
**Built-in** runs Whisper as ONNX inside the app through Transformers.js, with
|
|
102
|
+
nothing else to install. Choose the model (tiny, base or small; English-only or
|
|
103
|
+
multilingual), the weight precision, and whether to compute on CPU (WASM) or
|
|
104
|
+
GPU (WebGPU) — WebGPU falls back to WASM when it is unavailable. The panel shows
|
|
105
|
+
the download for the chosen combination, from about 41 MB for tiny at q8 to
|
|
106
|
+
about 968 MB for small at full precision; models are cached after first use.
|
|
107
|
+
Multilingual models add language and transcribe/translate options, which
|
|
108
|
+
English-only checkpoints reject and so do not show.
|
|
109
|
+
|
|
110
|
+
**Local server** posts the recorded audio as a WAV file to a transcription
|
|
111
|
+
server on this machine, using the OpenAI `/v1/audio/transcriptions` shape that
|
|
112
|
+
whisper.cpp's server, LM Studio, faster-whisper-server and similar tools speak.
|
|
113
|
+
This is the way to use a model the built-in engine cannot load — a GGUF build
|
|
114
|
+
such as `unslothai/Qwen3-ASR-0.6B-GGUF` needs a llama.cpp-family runtime, so
|
|
115
|
+
something else has to host it. The URL must be on this machine; a non-loopback
|
|
116
|
+
address is refused rather than sent.
|
|
117
|
+
|
|
118
|
+
Both engines share the maximum utterance length and the silence threshold, and
|
|
119
|
+
the **Try it** button on that page records a phrase and shows the transcript,
|
|
120
|
+
the recording level and how long transcription took, without typing into a
|
|
121
|
+
terminal. It transcribes even below the silence threshold and says so, which is
|
|
122
|
+
how the threshold gets tuned for a particular microphone.
|
|
123
|
+
|
|
124
|
+
## Installation and usage
|
|
125
|
+
|
|
126
|
+
The simplest way to try the published package is through `npx`:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx zerogterm
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The package downloads the application and launches it. To use the launcher repeatedly without downloading on each invocation, install it globally:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm install --global zerogterm
|
|
136
|
+
zerogterm
|
|
137
|
+
```
|
|
138
|
+
|
|
33
139
|
## Development
|
|
34
140
|
|
|
35
141
|
```bash
|
|
@@ -40,34 +146,43 @@ npm run build
|
|
|
40
146
|
npm start
|
|
41
147
|
```
|
|
42
148
|
|
|
43
|
-
|
|
149
|
+
Node.js is the only prerequisite every host needs. The scripts above run on Linux
|
|
150
|
+
and Windows; npm runs them through `cmd.exe` on Windows, so no POSIX shell is
|
|
151
|
+
required whichever shell you start them from.
|
|
44
152
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
153
|
+
`node-pty` provides terminal I/O on every platform. It ships prebuilt binaries
|
|
154
|
+
for Windows and macOS, so a C/C++ toolchain is a Linux requirement rather than a
|
|
155
|
+
general one — there, `npm install` compiles it.
|
|
48
156
|
|
|
49
|
-
|
|
157
|
+
### Linux
|
|
50
158
|
|
|
51
159
|
```bash
|
|
52
160
|
sudo dnf install screen make gcc-c++ python3
|
|
53
|
-
npm install node-pty
|
|
54
161
|
```
|
|
55
162
|
|
|
56
|
-
`
|
|
163
|
+
`make`, `gcc-c++` and `python3` build `node-pty`. `screen` is optional but worth
|
|
164
|
+
having: with it, local sessions are persistent and rediscovered after relaunch.
|
|
165
|
+
Without it, ZeroG falls back to a direct PTY on the chosen shell and labels the
|
|
166
|
+
session process-only; that shell is lost when the application exits.
|
|
57
167
|
|
|
58
|
-
|
|
59
|
-
sudo dnf install screen
|
|
60
|
-
```
|
|
168
|
+
### Windows
|
|
61
169
|
|
|
62
|
-
|
|
170
|
+
No tooling beyond Node.js. The new-terminal dialog offers the shells it finds on
|
|
171
|
+
PATH — Windows PowerShell, PowerShell 7, Command Prompt, WSL (with a distribution
|
|
172
|
+
picker), and Git Bash where Git for Windows is installed.
|
|
173
|
+
|
|
174
|
+
`screen` does not exist on Windows, so local sessions are always process-only and
|
|
175
|
+
do not survive app exit. Remote SSH sessions are unaffected by the local shell
|
|
176
|
+
backend, and a remote host with `screen` still gives persistent sessions there.
|
|
177
|
+
|
|
178
|
+
## Verification
|
|
179
|
+
|
|
180
|
+
The current main branch has the following local verification coverage:
|
|
63
181
|
|
|
64
182
|
- `npm run typecheck`: passes.
|
|
65
|
-
- `npm test`: passes (
|
|
183
|
+
- `npm test`: passes (167 tests covering session service behaviour and PTY sizing, shell discovery, SSH inventory and argument validation, remote-screen parsing and prompt readiness, session history, the session dialog, settings, terminal clipboard and OSC 52 handling, dialog dismissal, and the speech and voice helpers; one further test needs a real `screen` and is opt-in through `ZEROG_LIVE_SCREEN=1`).
|
|
66
184
|
- `npm run build`: passes and writes `dist/main` plus `dist/renderer`.
|
|
67
|
-
-
|
|
68
|
-
- `npm audit --omit=dev`: reports no known production vulnerabilities.
|
|
69
|
-
|
|
70
|
-
The live screen + node-pty smoke test creates a temporary named session, writes a marker through the PTY, observes it, and cleans up the session.
|
|
185
|
+
- `npm audit --omit=dev`: production dependency auditing is part of the project quality checks.
|
|
71
186
|
|
|
72
187
|
## License
|
|
73
188
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// System clipboard writes, verified.
|
|
2
|
+
//
|
|
3
|
+
// Only the main process can reach the OS clipboard, and Electron's writeText
|
|
4
|
+
// returns nothing: when the write loses a race for the clipboard — common on
|
|
5
|
+
// Windows, where a single owner holds it and clipboard-history tools poll it —
|
|
6
|
+
// the renderer has no way to know the text never landed. Reading back turns
|
|
7
|
+
// that silent loss into a retry, and then into a reportable error.
|
|
8
|
+
/**
|
|
9
|
+
* Did the text land, allowing for line-ending translation?
|
|
10
|
+
*
|
|
11
|
+
* Windows stores clipboard text with CRLF line endings, so a multi-line copy
|
|
12
|
+
* legitimately reads back changed. Comparing raw would report every multi-line
|
|
13
|
+
* copy as a failure.
|
|
14
|
+
*/
|
|
15
|
+
export function clipboardTextMatches(written, readBack) {
|
|
16
|
+
return written.replace(/\r\n/g, '\n') === readBack.replace(/\r\n/g, '\n');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Write text to the clipboard and confirm it is there, retrying once.
|
|
20
|
+
*
|
|
21
|
+
* The retry is the useful part: clipboard ownership contention is momentary,
|
|
22
|
+
* and a second attempt normally succeeds where the first was refused.
|
|
23
|
+
*/
|
|
24
|
+
export function writeClipboardText(clipboard, text) {
|
|
25
|
+
clipboard.writeText(text);
|
|
26
|
+
if (clipboardTextMatches(text, clipboard.readText()))
|
|
27
|
+
return true;
|
|
28
|
+
clipboard.writeText(text);
|
|
29
|
+
return clipboardTextMatches(text, clipboard.readText());
|
|
30
|
+
}
|
package/dist/main/main/main.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { app, BrowserWindow, clipboard, ipcMain, Menu, session } from 'electron';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import { writeClipboardText } from './clipboard.js';
|
|
5
|
+
import { ScreenService, parseWslDistributions } from './session-service.js';
|
|
6
|
+
import { discoverShellBackends } from './shell-catalog.js';
|
|
5
7
|
import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
|
|
6
8
|
import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
|
|
7
9
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
8
10
|
const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
|
|
9
11
|
const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
|
|
10
12
|
let win;
|
|
13
|
+
/** A pane's measured size, as it arrives from the renderer. */
|
|
14
|
+
function parsePtySize(value) {
|
|
15
|
+
if (!value || typeof value !== 'object')
|
|
16
|
+
return undefined;
|
|
17
|
+
const { cols, rows } = value;
|
|
18
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows))
|
|
19
|
+
return undefined;
|
|
20
|
+
return { cols: cols, rows: rows };
|
|
21
|
+
}
|
|
11
22
|
// GPU is unstable under Toolbox/Wayland on this host; allow override.
|
|
12
23
|
if (process.env.ZEROG_ENABLE_GPU !== '1') {
|
|
13
24
|
app.disableHardwareAcceleration();
|
|
@@ -106,10 +117,10 @@ ipcMain.handle('screens:attachRemote', (_event, input, screenName) => {
|
|
|
106
117
|
throw new Error('screenName is required');
|
|
107
118
|
return buildRemoteScreenAttachArgs(connection, screenName);
|
|
108
119
|
});
|
|
109
|
-
ipcMain.handle('sessions:attach', (_event, id) => {
|
|
120
|
+
ipcMain.handle('sessions:attach', (_event, id, size) => {
|
|
110
121
|
if (typeof id !== 'string' || !id)
|
|
111
122
|
throw new Error('attachSession requires a session id');
|
|
112
|
-
return service.attach(id, (data) => win?.webContents.send('terminal:data', id, data), (message) => win?.webContents.send('terminal:status', id, message));
|
|
123
|
+
return service.attach(id, (data) => win?.webContents.send('terminal:data', id, data), (message) => win?.webContents.send('terminal:status', id, message), parsePtySize(size));
|
|
113
124
|
});
|
|
114
125
|
ipcMain.handle('sessions:close', (_event, id) => {
|
|
115
126
|
if (typeof id !== 'string' || !id)
|
|
@@ -128,7 +139,11 @@ ipcMain.on('terminal:resize', (_event, sessionId, cols, rows) => {
|
|
|
128
139
|
ipcMain.handle('clipboard:writeText', (_event, text) => {
|
|
129
140
|
if (typeof text !== 'string')
|
|
130
141
|
throw new Error('clipboard:writeText requires a string');
|
|
131
|
-
|
|
142
|
+
// Reject rather than resolve on a lost write: the renderer shows the message
|
|
143
|
+
// in the status bar, so the user is not told text was copied when it was not.
|
|
144
|
+
if (!writeClipboardText(clipboard, text)) {
|
|
145
|
+
throw new Error('Clipboard write failed — another application is holding the clipboard');
|
|
146
|
+
}
|
|
132
147
|
});
|
|
133
148
|
ipcMain.handle('clipboard:readText', () => clipboard.readText());
|
|
134
149
|
ipcMain.handle('ai:suggest', () => ({
|
|
@@ -11,7 +11,7 @@ const api = {
|
|
|
11
11
|
listKnownConnections: () => ipcRenderer.invoke('connections:listKnown'),
|
|
12
12
|
discoverRemoteScreens: (connection) => ipcRenderer.invoke('screens:discoverRemote', connection),
|
|
13
13
|
buildRemoteScreenAttach: (connection, screenName) => ipcRenderer.invoke('screens:attachRemote', connection, screenName),
|
|
14
|
-
attachSession: (id) => ipcRenderer.invoke('sessions:attach', id),
|
|
14
|
+
attachSession: (id, size) => ipcRenderer.invoke('sessions:attach', id, size),
|
|
15
15
|
closeSession: (id) => ipcRenderer.invoke('sessions:close', id),
|
|
16
16
|
write: (sessionId, data) => ipcRenderer.send('terminal:write', sessionId, data),
|
|
17
17
|
resize: (sessionId, cols, rows) => ipcRenderer.send('terminal:resize', sessionId, cols, rows),
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { defaultShellBackend, isLocalShellBackend, resolveShellBackend } from './shell-catalog.js';
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
@@ -13,6 +14,26 @@ const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
|
13
14
|
* -chosen config file (hence ProxyCommand) without any shell involvement.
|
|
14
15
|
*/
|
|
15
16
|
const SSH_TARGET = /^(?:([A-Za-z0-9][A-Za-z0-9._-]*)@)?([A-Za-z0-9][A-Za-z0-9.-]*)(?::(\d{1,5}))?$/;
|
|
17
|
+
/**
|
|
18
|
+
* Size a pty starts at when the pane could not be measured — a placeholder,
|
|
19
|
+
* not a preference. Panes pass their real size through attach().
|
|
20
|
+
*/
|
|
21
|
+
const FALLBACK_SIZE = { cols: 120, rows: 32 };
|
|
22
|
+
/**
|
|
23
|
+
* A size a pty can actually be given. Sizes cross the IPC boundary from the
|
|
24
|
+
* renderer, and a zero or fractional one is rejected by ConPTY (and makes
|
|
25
|
+
* every full-screen program wrap in the wrong place on Unix).
|
|
26
|
+
*/
|
|
27
|
+
function usableSize(size) {
|
|
28
|
+
if (!size)
|
|
29
|
+
return undefined;
|
|
30
|
+
const { cols, rows } = size;
|
|
31
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows))
|
|
32
|
+
return undefined;
|
|
33
|
+
if (cols < 2 || rows < 2 || cols > 2000 || rows > 2000)
|
|
34
|
+
return undefined;
|
|
35
|
+
return { cols, rows };
|
|
36
|
+
}
|
|
16
37
|
export function validateSessionName(name) {
|
|
17
38
|
const value = name.trim();
|
|
18
39
|
if (!NAME.test(value)) {
|
|
@@ -45,30 +66,6 @@ export function parseWslDistributions(output) {
|
|
|
45
66
|
return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
|
|
46
67
|
.map((line) => line.split(/\s{2,}/)[0]).filter((name) => /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(name));
|
|
47
68
|
}
|
|
48
|
-
export function shellBackendArgs(backend, distribution) {
|
|
49
|
-
if (backend === 'wsl') {
|
|
50
|
-
if (distribution && !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(distribution.trim()))
|
|
51
|
-
throw new Error('Invalid WSL distribution name.');
|
|
52
|
-
return { backend, executable: process.platform === 'win32' ? 'wsl.exe' : 'wsl', args: distribution ? ['-d', distribution.trim()] : [], label: distribution ? `WSL · ${distribution.trim()}` : 'WSL', wslDistribution: distribution?.trim() };
|
|
53
|
-
}
|
|
54
|
-
if (backend === 'powershell')
|
|
55
|
-
return { backend, executable: process.platform === 'win32' ? 'pwsh.exe' : 'pwsh', args: [], label: 'PowerShell' };
|
|
56
|
-
if (backend === 'zsh')
|
|
57
|
-
return { backend, executable: 'zsh', args: [], label: 'zsh' };
|
|
58
|
-
return { backend: 'bash', executable: 'bash', args: [], label: 'bash' };
|
|
59
|
-
}
|
|
60
|
-
export async function discoverShellBackends() {
|
|
61
|
-
const result = [shellBackendArgs('bash')];
|
|
62
|
-
for (const backend of ['powershell', 'wsl']) {
|
|
63
|
-
const candidate = shellBackendArgs(backend);
|
|
64
|
-
try {
|
|
65
|
-
await execFileAsync(candidate.executable, backend === 'wsl' ? ['--status'] : ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()']);
|
|
66
|
-
result.push(candidate);
|
|
67
|
-
}
|
|
68
|
-
catch { /* unavailable */ }
|
|
69
|
-
}
|
|
70
|
-
return result;
|
|
71
|
-
}
|
|
72
69
|
export function parseScreenList(output) {
|
|
73
70
|
return output
|
|
74
71
|
.split('\n')
|
|
@@ -110,7 +107,12 @@ export class ScreenService {
|
|
|
110
107
|
sshSessions = new Map();
|
|
111
108
|
fallbackLocalSessions = new Map();
|
|
112
109
|
onEvent;
|
|
113
|
-
|
|
110
|
+
/** Injectable so tests can exercise routing and teardown without a real shell. */
|
|
111
|
+
spawnPty;
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
this.onEvent = options.onEvent;
|
|
114
|
+
this.spawnPty = options.spawnPty ?? ((file, args, ptyOptions) => loadPty().spawn(file, args, ptyOptions));
|
|
115
|
+
}
|
|
114
116
|
async available() {
|
|
115
117
|
try {
|
|
116
118
|
await execFileAsync('screen', ['--version']);
|
|
@@ -154,11 +156,15 @@ export class ScreenService {
|
|
|
154
156
|
async createLocal(nameOrRequest, cwd = homedir()) {
|
|
155
157
|
const request = typeof nameOrRequest === 'string' ? { name: nameOrRequest, cwd } : nameOrRequest;
|
|
156
158
|
const safeName = validateSessionName(request.name);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
// No backend named means "whatever this machine prefers": a native shell
|
|
160
|
+
// on Windows, the login shell or bash on Unix. resolveShellBackend throws by
|
|
161
|
+
// name when a named backend is not installed, so a missing shell is a clear
|
|
162
|
+
// message rather than a pty that dies on spawn.
|
|
163
|
+
const shell = request.backend
|
|
164
|
+
? resolveShellBackend(request.backend, request.wslDistribution)
|
|
165
|
+
: defaultShellBackend();
|
|
166
|
+
const backend = shell.backend;
|
|
159
167
|
const requestedCwd = request.cwd ?? homedir();
|
|
160
|
-
if (backend !== 'bash' && !(await executableAvailable(shell.executable)))
|
|
161
|
-
throw new Error(`${shell.label} is not installed or unavailable.`);
|
|
162
168
|
if (!(await this.available())) {
|
|
163
169
|
const fallback = { id: `local:${safeName}`, name: safeName, kind: 'local', host: 'local', cwd: requestedCwd, status: 'detached', lastSeen: new Date().toISOString(), persistence: 'process', backend, scope: 'local', source: 'active', wslDistribution: shell.wslDistribution };
|
|
164
170
|
this.fallbackLocalSessions.set(fallback.id, fallback);
|
|
@@ -190,24 +196,39 @@ export class ScreenService {
|
|
|
190
196
|
this.onEvent?.('created', session, true);
|
|
191
197
|
return session;
|
|
192
198
|
}
|
|
193
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Attach a pane to a session, starting its pty if it does not have one.
|
|
201
|
+
*
|
|
202
|
+
* `size` is the pane's own measurement. A pty spawned at some stock size and
|
|
203
|
+
* resized a moment later shows its first frame at the wrong width, and a
|
|
204
|
+
* full-screen program redrawing over that frame leaves pieces of it behind —
|
|
205
|
+
* so the shell is started at the size it will actually be displayed at.
|
|
206
|
+
*/
|
|
207
|
+
attach(id, onData, onExit, size) {
|
|
194
208
|
const existing = this.getSession(id);
|
|
195
|
-
if (this.ptys.has(id))
|
|
209
|
+
if (this.ptys.has(id)) {
|
|
210
|
+
// Re-attaching pane may be a different size than the one that started it.
|
|
211
|
+
const measured = usableSize(size);
|
|
212
|
+
if (measured)
|
|
213
|
+
this.resize(id, measured.cols, measured.rows);
|
|
196
214
|
return { ...existing, status: 'connected' };
|
|
215
|
+
}
|
|
197
216
|
if (id.startsWith('local:')) {
|
|
198
217
|
const fallback = this.fallbackLocalSessions.get(id);
|
|
199
218
|
if (fallback) {
|
|
200
219
|
fallback.status = 'connected';
|
|
201
220
|
fallback.lastSeen = new Date().toISOString();
|
|
202
|
-
const shell =
|
|
203
|
-
|
|
221
|
+
const shell = isLocalShellBackend(fallback.backend)
|
|
222
|
+
? resolveShellBackend(fallback.backend, fallback.wslDistribution)
|
|
223
|
+
: defaultShellBackend();
|
|
224
|
+
this.spawnCommand(id, shell.executable, shell.args, onData, onExit, fallback.cwd, size);
|
|
204
225
|
this.onEvent?.('attached', fallback, true);
|
|
205
226
|
return { ...fallback };
|
|
206
227
|
}
|
|
207
228
|
// Session ids cross the IPC boundary from the renderer, so re-validate
|
|
208
229
|
// rather than trusting that createLocal produced this one.
|
|
209
230
|
const name = validateSessionName(id.slice('local:'.length));
|
|
210
|
-
this.spawnCommand(id, 'screen', ['-x', name], onData, onExit);
|
|
231
|
+
this.spawnCommand(id, 'screen', ['-x', name], onData, onExit, homedir(), size);
|
|
211
232
|
this.onEvent?.('attached', existing, true);
|
|
212
233
|
return { ...existing, status: 'connected', persistence: 'screen' };
|
|
213
234
|
}
|
|
@@ -219,7 +240,7 @@ export class ScreenService {
|
|
|
219
240
|
session.status = 'connected';
|
|
220
241
|
session.lastSeen = new Date().toISOString();
|
|
221
242
|
try {
|
|
222
|
-
this.spawnCommand(id, 'ssh', args, onData, onExit);
|
|
243
|
+
this.spawnCommand(id, 'ssh', args, onData, onExit, homedir(), size);
|
|
223
244
|
}
|
|
224
245
|
catch (error) {
|
|
225
246
|
session.status = 'error';
|
|
@@ -251,12 +272,12 @@ export class ScreenService {
|
|
|
251
272
|
throw new Error(`Unknown session: ${id}`);
|
|
252
273
|
return session;
|
|
253
274
|
}
|
|
254
|
-
spawnCommand(sessionId, file, args, onData, onExit, cwd = homedir()) {
|
|
255
|
-
const
|
|
256
|
-
const proc =
|
|
275
|
+
spawnCommand(sessionId, file, args, onData, onExit, cwd = homedir(), size) {
|
|
276
|
+
const { cols, rows } = usableSize(size) ?? FALLBACK_SIZE;
|
|
277
|
+
const proc = this.spawnPty(file, args, {
|
|
257
278
|
name: 'xterm-256color',
|
|
258
|
-
cols
|
|
259
|
-
rows
|
|
279
|
+
cols,
|
|
280
|
+
rows,
|
|
260
281
|
cwd,
|
|
261
282
|
env: process.env
|
|
262
283
|
});
|
|
@@ -272,7 +293,7 @@ export class ScreenService {
|
|
|
272
293
|
}
|
|
273
294
|
}
|
|
274
295
|
};
|
|
275
|
-
this.ptys.set(sessionId, { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local' });
|
|
296
|
+
this.ptys.set(sessionId, { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local', cols, rows });
|
|
276
297
|
proc.onData(onData);
|
|
277
298
|
proc.onExit(() => {
|
|
278
299
|
this.ptys.delete(sessionId);
|
|
@@ -294,8 +315,25 @@ export class ScreenService {
|
|
|
294
315
|
write(sessionId, data) {
|
|
295
316
|
this.ptys.get(sessionId)?.handle.write(data);
|
|
296
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Tell a session's pty how big its pane is now.
|
|
320
|
+
*
|
|
321
|
+
* A resize is not free and not invisible: it makes ConPTY reflow and re-emit
|
|
322
|
+
* its screen, and every full-screen program redraw on SIGWINCH. Panes refit
|
|
323
|
+
* on layout, font and status changes, which mostly produce the size the pty
|
|
324
|
+
* already has, so a repeat of the current size is dropped rather than paid
|
|
325
|
+
* for in a torn redraw.
|
|
326
|
+
*/
|
|
297
327
|
resize(sessionId, cols, rows) {
|
|
298
|
-
this.ptys.get(sessionId)
|
|
328
|
+
const pty = this.ptys.get(sessionId);
|
|
329
|
+
if (!pty)
|
|
330
|
+
return;
|
|
331
|
+
const size = usableSize({ cols, rows });
|
|
332
|
+
if (!size || (size.cols === pty.cols && size.rows === pty.rows))
|
|
333
|
+
return;
|
|
334
|
+
pty.cols = size.cols;
|
|
335
|
+
pty.rows = size.rows;
|
|
336
|
+
pty.handle.resize(size.cols, size.rows);
|
|
299
337
|
}
|
|
300
338
|
detach(sessionId) {
|
|
301
339
|
this.ptys.get(sessionId)?.handle.kill();
|
|
@@ -318,10 +356,3 @@ export class ScreenService {
|
|
|
318
356
|
this.onEvent?.('closed', session, false);
|
|
319
357
|
}
|
|
320
358
|
}
|
|
321
|
-
async function executableAvailable(executable) { try {
|
|
322
|
-
await execFileAsync(executable, ['--version']);
|
|
323
|
-
return true;
|
|
324
|
-
}
|
|
325
|
-
catch {
|
|
326
|
-
return false;
|
|
327
|
-
} }
|