Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const THEMES: Record<string, TerminalTheme> = {
// ── Persistent prefs ──────────────────────────────────────────────────────────
const PREFS_KEY = 'web-terminal-prefs';
const WEBGL_DISABLED_KEY = 'web-terminal-disable-webgl';
const DEFAULT_FONT_FAMILY = '"Cascadia Mono", Consolas, "DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "Noto Sans Mono CJK JP", "Noto Sans CJK JP", "Microsoft YaHei", "MS Gothic", Meiryo, "PingFang SC", "Hiragino Sans GB", "Noto Color Emoji", Menlo, Monaco, "Courier New", monospace';
const DEFAULT_FONT_FAMILY = '"SF Mono", "SFMono-Regular", Menlo, "Cascadia Mono", "JetBrains Mono", Consolas, "DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "Noto Sans Mono CJK JP", "Noto Sans CJK JP", "Microsoft YaHei", "MS Gothic", Meiryo, "PingFang SC", "Hiragino Sans GB", "Noto Color Emoji", Monaco, "Courier New", monospace';
function isWebglDisabled(): boolean { try { return localStorage.getItem(WEBGL_DISABLED_KEY) === 'true'; } catch { return false; } }
function loadPrefs(): Partial<Prefs> { try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); } catch { return {}; } }
function savePrefs(p: Prefs): void { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch { /* ignore */ } }
Expand Down Expand Up @@ -313,6 +313,7 @@ interface SessionOptions {
Terminal: any; FitAddon: any; WebLinksAddon: any; WebglAddon: any;
ClipboardAddon: any; Unicode11Addon: any;
prefs: Prefs;
cwd?: string;
onChange: (id: string, status: string) => void;
}

Expand All @@ -322,6 +323,7 @@ class TerminalSession {
status: string;
onChange: (id: string, status: string) => void;
prefs: Prefs;
cwd: string;
el: HTMLElement;
overlayEl: HTMLElement;
terminal: any;
Expand All @@ -343,6 +345,7 @@ class TerminalSession {
this.status = 'connecting';
this.onChange = opts.onChange;
this.prefs = opts.prefs;
this.cwd = opts.cwd || '';
this._destroyed = false;
this._reconnectTimer = null;
this._reconnectAttempts = 0;
Expand All @@ -355,8 +358,15 @@ class TerminalSession {

this.terminal = new opts.Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontSize: opts.prefs.fontSize || 14,
fontFamily: opts.prefs.fontFamily || DEFAULT_FONT_FAMILY,
fontWeight: 400,
fontWeightBold: 600,
lineHeight: 1.2,
letterSpacing: 0.4,
minimumContrastRatio: 1.1,
drawBoldTextInBrightColors: true,
allowProposedApi: true, convertEol: true, scrollback: 10000,
tabStopWidth: 4, macOptionIsMeta: true, macOptionClickForcesSelection: true,
theme: THEMES[opts.prefs.theme || 'VS Dark'],
Expand Down Expand Up @@ -431,6 +441,11 @@ class TerminalSession {
ws.binaryType = 'arraybuffer';
this.ws = ws;

ws.onopen = () => {
// Tell the server where to start the shell (selected project, else $HOME).
try { ws.send(JSON.stringify({ type: 'init', cwd: this.cwd || '' })); } catch { /* ignore */ }
};

ws.onmessage = (ev: MessageEvent) => {
let d = ev.data;
// Decode binary frames to text (happens when behind reverse proxies)
Expand Down Expand Up @@ -637,6 +652,11 @@ export async function mount(container: HTMLElement, api: PluginAPI): Promise<voi
const prefs = _G.prefs;
const isLight = (): boolean => prefs.theme === 'Light';

// Working directory for new shells: the selected CloudCLI project, else $HOME
// (empty string lets the server pick the home directory). Kept in sync as the
// user switches projects; existing tabs keep their original directory.
let projectCwd = api.context?.project?.path || '';

const root = el('div', 'wt-root' + (isLight() ? ' wt-light' : ''));
container.appendChild(root);

Expand Down Expand Up @@ -780,7 +800,7 @@ export async function mount(container: HTMLElement, api: PluginAPI): Promise<voi
Terminal: mods.Terminal, FitAddon: mods.FitAddon,
WebLinksAddon: mods.WebLinksAddon, WebglAddon: mods.WebglAddon,
ClipboardAddon: mods.ClipboardAddon, Unicode11Addon: mods.Unicode11Addon,
prefs, onChange() { renderTabs(); },
prefs, cwd: projectCwd, onChange() { renderTabs(); },
});
_G.sessions.set(id, sess);
sess.attachTo(panesEl);
Expand Down Expand Up @@ -841,7 +861,9 @@ export async function mount(container: HTMLElement, api: PluginAPI): Promise<voi
}
};
document.addEventListener('keydown', onKey);
const unsubCtx = api.onContextChange ? api.onContextChange(() => {}) : null;
const unsubCtx = api.onContextChange
? api.onContextChange((ctx) => { projectCwd = ctx?.project?.path || ''; })
: null;

(container as any)._wtCleanup = () => {
document.removeEventListener('keydown', onKey);
Expand Down
146 changes: 92 additions & 54 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface WsMessage {
data?: string;
cols?: number;
rows?: number;
cwd?: string;
}

// ── Module finder ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -86,7 +87,15 @@ let sessionCounter = 0;

function getShell(): string {
if (process.platform === 'win32') return 'powershell.exe';
return process.env.SHELL || '/bin/bash';
if (process.env.SHELL) return process.env.SHELL;
// The plugin server is often spawned without SHELL in its env (e.g. from a GUI
// launch context), which would drop the user to a bare /bin/bash with none of
// their prompt/colors. Fall back to the login shell from /etc/passwd.
try {
const loginShell = os.userInfo().shell;
if (loginShell) return loginShell;
} catch { /* ignore */ }
return '/bin/bash';
}

function prioritizeUserNpmGlobalBin(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
Expand Down Expand Up @@ -152,76 +161,105 @@ const server = http.createServer((req, res) => {

const wss = new WebSocketServer({ server, path: '/ws' });

// Resolve the requested working directory to a real directory, falling back to
// the user's home when it's missing, invalid, or not a directory.
function resolveCwd(requested?: unknown): string {
const home = process.env.HOME || os.homedir();
if (typeof requested === 'string' && requested.trim()) {
try {
const abs = path.resolve(requested);
if (fs.statSync(abs).isDirectory()) return abs;
} catch { /* fall through to home */ }
}
return home;
}

wss.on('connection', (ws: any) => {
const sessionId = `s${++sessionCounter}`;
const cwd = process.env.HOME || os.homedir();
const shell = getShell();

let ptyProc: PtyProcess;
try {
ptyProc = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd,

env: {
...prioritizeUserNpmGlobalBin(process.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'web-terminal',
},
encoding: null,

});
} catch (err) {
safeSend(ws, { type: 'error', message: `Failed to spawn shell: ${(err as Error).message}` });
ws.close();
return;
}

sessions.set(sessionId, { pty: ptyProc, ws });
safeSend(ws, { type: 'ready', sessionId, shell, cwd });

const decoder = new TextDecoder('utf-8', { fatal: false });

ptyProc.onData((chunk: string | Buffer) => {
const text = typeof chunk === 'string'
? chunk
: decoder.decode(chunk, { stream: true });

if (!text) {
let ptyProc: PtyProcess | null = null;
// The client sends an `init` message with the selected project path right
// after connecting, so the shell can start there instead of $HOME. If it
// never arrives (older client), spawn in $HOME shortly after connecting.
let spawnTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => startShell(), 400);

function startShell(requestedCwd?: unknown): void {
if (ptyProc) return;
if (spawnTimer) { clearTimeout(spawnTimer); spawnTimer = null; }
const cwd = resolveCwd(requestedCwd);

let proc: PtyProcess;
try {
proc = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd,
env: {
...prioritizeUserNpmGlobalBin(process.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'web-terminal',
},
encoding: null,
});
} catch (err) {
safeSend(ws, { type: 'error', message: `Failed to spawn shell: ${(err as Error).message}` });
ws.close();
return;
}

ptyProc.pause();
if (ws.readyState === WebSocket.OPEN) {
ws.send(text, () => ptyProc.resume());
} else {
ptyProc.resume();
}
});
ptyProc = proc;
sessions.set(sessionId, { pty: proc, ws });
safeSend(ws, { type: 'ready', sessionId, shell, cwd });

proc.onData((chunk: string | Buffer) => {
const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
if (!text) return;
proc.pause();
if (ws.readyState === WebSocket.OPEN) {
ws.send(text, () => proc.resume());
} else {
proc.resume();
}
});

ptyProc.onExit(({ exitCode, signal }) => {
sessions.delete(sessionId);
safeSend(ws, { type: 'exit', sessionId, exitCode, signal });
if (ws.readyState === WebSocket.OPEN) ws.close(1000, 'shell exited');
});
proc.onExit(({ exitCode, signal }) => {
sessions.delete(sessionId);
safeSend(ws, { type: 'exit', sessionId, exitCode, signal });
if (ws.readyState === WebSocket.OPEN) ws.close(1000, 'shell exited');
});
}

ws.on('message', (rawData: Buffer | string) => {
const text = Buffer.isBuffer(rawData) ? rawData.toString('utf8') : String(rawData);

let msg: WsMessage | null = null;
if (text.charCodeAt(0) === 123) {
try {
const msg: WsMessage = JSON.parse(text);
if (msg.type === 'input' && typeof msg.data === 'string') { ptyProc.write(msg.data); return; }
if (msg.type === 'resize') { ptyProc.resize(Math.max(1, Math.min(Number(msg.cols) || 80, 500)), Math.max(1, Math.min(Number(msg.rows) || 24, 200))); return; }
if (msg.type === 'ping') { safeSend(ws, { type: 'pong', sessionId }); return; }
} catch { /* fall through */ }
try { msg = JSON.parse(text) as WsMessage; } catch { msg = null; }
}

if (msg && msg.type === 'init') { startShell(msg.cwd); return; }

// Any traffic before an init message starts the shell in $HOME.
if (!ptyProc) startShell();
if (!ptyProc) return; // spawn failed

if (msg) {
if (msg.type === 'input' && typeof msg.data === 'string') { ptyProc.write(msg.data); return; }
if (msg.type === 'resize') { ptyProc.resize(Math.max(1, Math.min(Number(msg.cols) || 80, 500)), Math.max(1, Math.min(Number(msg.rows) || 24, 200))); return; }
if (msg.type === 'ping') { safeSend(ws, { type: 'pong', sessionId }); return; }
}
ptyProc.write(text);
});

ws.on('close', () => { sessions.delete(sessionId); try { ptyProc.kill(); } catch { /* ignore */ } });
ws.on('close', () => {
if (spawnTimer) { clearTimeout(spawnTimer); spawnTimer = null; }
sessions.delete(sessionId);
if (ptyProc) { try { ptyProc.kill(); } catch { /* ignore */ } }
});
ws.on('error', (err: Error) => { console.error(`[web-terminal] ${sessionId} error:`, err.message); });
});

Expand Down