feat: add vertical split layout with two independent terminals

This commit is contained in:
2026-03-21 21:36:13 +08:00
parent 9179b7989e
commit 7b1b3bce89
2 changed files with 71 additions and 112 deletions

View File

@@ -22,11 +22,10 @@
<span class="brightness-icon"></span>
</div>
</div>
<div id="terminal"></div>
<div class="terminal-status">
<span id="connection-status">Connecting...</span>
<span id="shell-name"></span>
<span id="wasm-status"></span>
<div id="terminals-container">
<div class="terminal-pane" id="terminal-top"></div>
<div class="terminal-divider"></div>
<div class="terminal-pane" id="terminal-bottom"></div>
</div>
</div>
@@ -35,17 +34,15 @@
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.16.0/lib/addon-webgl.min.js"></script>
<script src="ghostty-vt.js"></script>
<script>
const terminalContainer = document.getElementById('terminal');
const connectionStatus = document.getElementById('connection-status');
const shellName = document.getElementById('shell-name');
const wasmStatus = document.getElementById('wasm-status');
const terminalTop = document.getElementById('terminal-top');
const terminalBottom = document.getElementById('terminal-bottom');
const urlParams = new URLSearchParams(window.location.search);
const shell = urlParams.get('shell') || (window.navigator.platform.startsWith('Win') ? 'powershell.exe' : 'bash');
shellName.textContent = shell;
let ws;
let term;
let ws1, ws2;
let term1, term2;
let fitAddon1, fitAddon2;
let currentBrightness = 0;
function getThemeForBrightness(brightness) {
@@ -112,21 +109,22 @@
brightWhite: '#ffffff'
};
if (term) {
term.options.theme = terminalTheme;
if (term1) {
term1.options.theme = terminalTheme;
}
if (term2) {
term2.options.theme = terminalTheme;
}
return { bgColor, headerBg, statusBg, textColor, borderColor };
return { bgColor, headerBg, borderColor };
}
function updateBrightness(brightness) {
currentBrightness = brightness;
const { bgColor, headerBg, statusBg, borderColor } = getThemeForBrightness(brightness);
const { bgColor, headerBg, borderColor } = getThemeForBrightness(brightness);
document.body.style.background = bgColor;
document.querySelector('.terminal-header').style.background = headerBg;
document.querySelector('.terminal-header').style.borderBottomColor = borderColor;
document.querySelector('.terminal-status').style.background = statusBg;
document.querySelector('.terminal-status').style.borderTopColor = borderColor;
const sliderThumb = document.querySelector('#brightness-slider::-webkit-slider-thumb');
if (sliderThumb) {
@@ -157,35 +155,18 @@
async function initGhosttyVT() {
const wasmUrl = urlParams.get('wasm') || 'ghostty-vt.wasm';
wasmStatus.textContent = 'Loading libghostty-vt...';
const loaded = await GhosttyVT.loadWASM(wasmUrl);
if (loaded) {
wasmStatus.textContent = 'libghostty-vt: Active';
wasmStatus.classList.add('active');
console.log('libghostty-vt: Active');
} else {
wasmStatus.textContent = 'libghostty-vt: Fallback (xterm.js)';
wasmStatus.classList.add('fallback');
console.log('libghostty-vt: Fallback (xterm.js)');
}
}
function connectTerminal() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}?shell=${encodeURIComponent(shell)}`;
connectionStatus.textContent = 'Connecting...';
terminalContainer.classList.add('connecting');
try {
ws = new WebSocket(wsUrl);
} catch (err) {
console.error('Failed to create WebSocket:', err);
connectionStatus.textContent = 'Connection failed';
return;
}
term = new Terminal({
function createTerminal(container, ws) {
const term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontSize: 14,
@@ -229,8 +210,7 @@
console.log('WebGL not available, using Canvas renderer');
}
term.loadAddon(fitAddon);
term.open(terminalContainer);
term.open(container);
setTimeout(() => {
fitAddon.fit();
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -241,15 +221,6 @@
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
console.log('WebSocket connected');
connectionStatus.textContent = 'Connected';
connectionStatus.classList.remove('connecting');
connectionStatus.classList.add('connected');
terminalContainer.classList.remove('connecting');
terminalContainer.classList.add('connected');
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
const text = new TextDecoder().decode(event.data);
@@ -259,18 +230,12 @@
}
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
connectionStatus.textContent = `Disconnected (${event.code})`;
connectionStatus.classList.remove('connected');
connectionStatus.classList.add('disconnected');
terminalContainer.classList.remove('connected');
ws.onclose = () => {
term.write('\r\n\x1b[33m[Connection closed]\x1b[0m\r\n');
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
connectionStatus.textContent = 'Error';
};
term.onData((data) => {
@@ -285,11 +250,35 @@
}
});
window.addEventListener('resize', () => {
fitAddon.fit();
});
return { term, fitAddon };
}
term.focus();
function connectTerminal() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}?shell=${encodeURIComponent(shell)}`;
try {
ws1 = new WebSocket(wsUrl);
ws2 = new WebSocket(wsUrl);
} catch (err) {
console.error('Failed to create WebSocket:', err);
return;
}
const result1 = createTerminal(terminalTop, ws1);
const result2 = createTerminal(terminalBottom, ws2);
term1 = result1.term;
term2 = result2.term;
fitAddon1 = result1.fitAddon;
fitAddon2 = result2.fitAddon;
term1.focus();
window.addEventListener('resize', () => {
fitAddon1.fit();
fitAddon2.fit();
});
}
setupBrightnessControl();

View File

@@ -79,20 +79,34 @@ html, body {
background: var(--accent);
}
#terminal {
#terminals-container {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
#terminal.connecting {
opacity: 0.6;
.terminal-pane {
flex: 1;
padding: 4px;
background: var(--bg-primary);
overflow: hidden;
}
.xterm {
.terminal-pane .xterm {
height: 100%;
}
.terminal-divider {
height: 4px;
background: var(--bg-secondary);
cursor: row-resize;
}
.terminal-divider:hover {
background: var(--accent);
}
.xterm-viewport::-webkit-scrollbar {
width: 8px;
}
@@ -106,57 +120,13 @@ html, body {
border-radius: 4px;
}
.terminal-status {
display: flex;
justify-content: space-between;
padding: 4px 16px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-secondary);
}
#connection-status.connecting {
color: #f0a050;
}
#connection-status.connected {
color: #50c878;
}
#connection-status.connected::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
background: #50c878;
border-radius: 50%;
margin-right: 6px;
}
#connection-status.disconnected {
color: #d35252;
}
#wasm-status.active {
color: #50c878;
}
#wasm-status.fallback {
color: #f0a050;
}
@media (max-width: 600px) {
.terminal-header {
padding: 6px 12px;
}
.terminal-status {
padding: 4px 12px;
}
#terminal {
padding: 4px;
.terminal-pane {
padding: 2px;
}
}