Files
Triple-C/app/src-tauri/src/web_terminal/terminal.html
Josh Knapp 57a7cee544
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 4m13s
Build App / build-linux (push) Successful in 4m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Make topbar and tab bar sticky in web terminal
Adds position:sticky to the topbar and tab bar so they stay pinned
at the top when the virtual keyboard opens on tablets. Also uses
100dvh (dynamic viewport height) so the layout properly shrinks
when the keyboard appears on mobile browsers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:15:31 -07:00

670 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Triple-C Web Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<style>
:root {
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #2f3347;
--text-primary: #c0caf5;
--text-secondary: #565f89;
--accent: #7aa2f7;
--accent-hover: #89b4fa;
--border: #3b3f57;
--success: #9ece6a;
--warning: #e0af68;
--error: #f7768e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
height: 100vh;
height: 100dvh; /* dynamic viewport height — shrinks when mobile keyboard opens */
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* ── Top Bar ─────────────────────────────── */
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 42px;
position: sticky;
top: 0;
z-index: 20;
}
.topbar-title {
font-size: 13px;
font-weight: 600;
color: var(--accent);
white-space: nowrap;
margin-right: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
flex-shrink: 0;
}
.status-dot.connected { background: var(--success); }
.status-dot.reconnecting { background: var(--warning); animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
select, button {
font-size: 12px;
padding: 4px 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
touch-action: manipulation;
}
select:focus, button:focus { outline: none; border-color: var(--accent); }
button:hover { background: var(--border); }
button:active { background: var(--accent); color: var(--bg-primary); }
.btn-new {
font-weight: 600;
min-width: 44px;
min-height: 32px;
}
/* ── Tab Bar ─────────────────────────────── */
.tabbar {
display: flex;
align-items: center;
gap: 1px;
padding: 0 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
min-height: 32px;
position: sticky;
top: 42px; /* below .topbar min-height */
z-index: 20;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: all 0.15s;
min-height: 32px;
}
.tab:hover { color: var(--text-primary); }
.tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent);
}
.tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 3px;
font-size: 12px;
line-height: 1;
color: var(--text-secondary);
background: none;
border: none;
padding: 0;
min-width: unset;
min-height: unset;
}
.tab-close:hover { background: var(--error); color: white; }
/* ── Terminal Area ───────────────────────── */
.terminal-area {
flex: 1;
position: relative;
overflow: hidden;
}
.terminal-container {
position: absolute;
inset: 0;
display: none;
padding: 4px;
}
.terminal-container.active { display: block; }
/* ── Input Bar (mobile/tablet) ──────────── */
.input-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.input-bar input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 16px; /* prevents iOS zoom on focus */
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
-webkit-appearance: none;
}
.input-bar input:focus { border-color: var(--accent); }
.input-bar .key-btn {
padding: 8px 10px;
font-size: 11px;
font-weight: 600;
min-width: 40px;
min-height: 36px;
border-radius: 6px;
white-space: nowrap;
}
/* ── Scroll-to-bottom FAB ──────────────── */
.scroll-bottom-btn {
position: absolute;
bottom: 12px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-primary);
border: none;
font-size: 18px;
font-weight: bold;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
z-index: 10;
padding: 0;
min-width: unset;
min-height: unset;
line-height: 1;
}
.scroll-bottom-btn:hover { background: var(--accent-hover); }
.scroll-bottom-btn.visible { display: flex; }
/* ── Empty State ─────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 14px;
gap: 12px;
}
.empty-state .hint {
font-size: 12px;
opacity: 0.7;
}
/* ── Scrollbar ───────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<!-- Top Bar -->
<div class="topbar">
<span class="topbar-title">Triple-C</span>
<span class="status-dot" id="statusDot"></span>
<select id="projectSelect" style="flex:1; max-width:240px;">
<option value="">Select project...</option>
</select>
<button class="btn-new" id="btnClaude" title="New Claude session">Claude</button>
<button class="btn-new" id="btnBash" title="New Bash session">Bash</button>
</div>
<!-- Tab Bar -->
<div class="tabbar" id="tabbar"></div>
<!-- Terminal Area -->
<div class="terminal-area" id="terminalArea">
<div class="empty-state" id="emptyState">
<div>Select a project and open a terminal session</div>
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
</div>
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">&#8595;</button>
</div>
<!-- Input Bar for mobile/tablet -->
<div class="input-bar" id="inputBar">
<input type="text" id="mobileInput" placeholder="Type here..."
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
enterkeyhint="send" inputmode="text">
<button class="key-btn" id="btnEnter">Enter</button>
<button class="key-btn" id="btnTab">Tab</button>
<button class="key-btn" id="btnCtrlC">^C</button>
</div>
<script>
(function() {
'use strict';
// ── State ──────────────────────────────────
const params = new URLSearchParams(window.location.search);
const TOKEN = params.get('token') || '';
let ws = null;
let reconnectTimer = null;
let sessions = {}; // { sessionId: { term, fitAddon, projectName, type, containerId } }
let activeSessionId = null;
// ── DOM refs ───────────────────────────────
const statusDot = document.getElementById('statusDot');
const projectSelect = document.getElementById('projectSelect');
const btnClaude = document.getElementById('btnClaude');
const btnBash = document.getElementById('btnBash');
const tabbar = document.getElementById('tabbar');
const terminalArea = document.getElementById('terminalArea');
const emptyState = document.getElementById('emptyState');
const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter');
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
// ── WebSocket ──────────────────────────────
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(TOKEN)}`;
ws = new WebSocket(url);
ws.onopen = () => {
statusDot.className = 'status-dot connected';
clearTimeout(reconnectTimer);
send({ type: 'list_projects' });
// Start keepalive
ws._pingInterval = setInterval(() => send({ type: 'ping' }), 30000);
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
handleMessage(msg);
} catch (e) {
console.error('Parse error:', e);
}
};
ws.onclose = () => {
statusDot.className = 'status-dot reconnecting';
if (ws && ws._pingInterval) clearInterval(ws._pingInterval);
reconnectTimer = setTimeout(connect, 2000);
};
ws.onerror = () => {
ws.close();
};
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
// ── Message handling ───────────────────────
function handleMessage(msg) {
switch (msg.type) {
case 'projects':
updateProjectList(msg.projects);
break;
case 'opened':
onSessionOpened(msg.session_id, msg.project_name);
break;
case 'output':
onSessionOutput(msg.session_id, msg.data);
break;
case 'exit':
onSessionExit(msg.session_id);
break;
case 'error':
console.error('Server error:', msg.message);
// Show in active terminal if available
if (activeSessionId && sessions[activeSessionId]) {
sessions[activeSessionId].term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`);
}
break;
case 'pong':
break;
}
}
function updateProjectList(projects) {
const current = projectSelect.value;
projectSelect.innerHTML = '<option value="">Select project...</option>';
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name} (${p.status})`;
opt.disabled = p.status !== 'running';
projectSelect.appendChild(opt);
});
// Restore selection if still valid
if (current) projectSelect.value = current;
}
// ── Session management ─────────────────────
let pendingSessionType = null;
function openSession(type) {
const projectId = projectSelect.value;
if (!projectId) {
alert('Please select a running project first.');
return;
}
pendingSessionType = type;
send({
type: 'open',
project_id: projectId,
session_type: type,
});
}
function onSessionOpened(sessionId, projectName) {
const sessionType = pendingSessionType || 'claude';
pendingSessionType = null;
// Create terminal
const term = new Terminal({
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
selectionBackground: '#33467c',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
cursorBlink: true,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
// Create container div
const container = document.createElement('div');
container.className = 'terminal-container';
container.id = `term-${sessionId}`;
terminalArea.appendChild(container);
term.open(container);
fitAddon.fit();
// Send initial resize
send({
type: 'resize',
session_id: sessionId,
cols: term.cols,
rows: term.rows,
});
// Handle user input
term.onData(data => {
const bytes = new TextEncoder().encode(data);
const b64 = btoa(String.fromCharCode(...bytes));
send({
type: 'input',
session_id: sessionId,
data: b64,
});
});
// Track scroll position for scroll-to-bottom button
term.onScroll(() => updateScrollButton());
// Store session
sessions[sessionId] = { term, fitAddon, projectName, type: sessionType, container };
// Add tab and switch to it
addTab(sessionId, projectName, sessionType);
switchToSession(sessionId);
emptyState.style.display = 'none';
}
function onSessionOutput(sessionId, b64data) {
const session = sessions[sessionId];
if (!session) return;
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
session.term.write(bytes);
// Update scroll button if this is the active session
if (sessionId === activeSessionId) updateScrollButton();
}
function onSessionExit(sessionId) {
const session = sessions[sessionId];
if (!session) return;
session.term.writeln('\r\n\x1b[90m[Session ended]\x1b[0m');
}
function closeSession(sessionId) {
send({ type: 'close', session_id: sessionId });
removeSession(sessionId);
}
function removeSession(sessionId) {
const session = sessions[sessionId];
if (!session) return;
session.term.dispose();
session.container.remove();
delete sessions[sessionId];
// Remove tab
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) tab.remove();
// Switch to another session or show empty state
const remaining = Object.keys(sessions);
if (remaining.length > 0) {
switchToSession(remaining[remaining.length - 1]);
} else {
activeSessionId = null;
emptyState.style.display = '';
}
}
// ── Tab bar ────────────────────────────────
function addTab(sessionId, projectName, sessionType) {
const tab = document.createElement('div');
tab.className = 'tab';
tab.id = `tab-${sessionId}`;
const label = document.createElement('span');
label.textContent = `${projectName} (${sessionType})`;
tab.appendChild(label);
const close = document.createElement('button');
close.className = 'tab-close';
close.textContent = '\u00d7';
close.onclick = (e) => { e.stopPropagation(); closeSession(sessionId); };
tab.appendChild(close);
tab.onclick = () => switchToSession(sessionId);
tabbar.appendChild(tab);
}
function switchToSession(sessionId) {
activeSessionId = sessionId;
// Update tab styles
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) tab.classList.add('active');
// Show/hide terminal containers
document.querySelectorAll('.terminal-container').forEach(c => c.classList.remove('active'));
const container = document.getElementById(`term-${sessionId}`);
if (container) {
container.classList.add('active');
const session = sessions[sessionId];
if (session) {
// Fit after making visible
requestAnimationFrame(() => {
session.fitAddon.fit();
session.term.focus();
updateScrollButton();
});
}
}
}
// ── Resize handling ────────────────────────
function handleResize() {
if (activeSessionId && sessions[activeSessionId]) {
const session = sessions[activeSessionId];
session.fitAddon.fit();
send({
type: 'resize',
session_id: activeSessionId,
cols: session.term.cols,
rows: session.term.rows,
});
}
}
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(handleResize, 100);
});
// ── Send helper ─────────────────────────────
function sendTerminalInput(str) {
if (!activeSessionId) return;
const bytes = new TextEncoder().encode(str);
const b64 = btoa(String.fromCharCode(...bytes));
send({
type: 'input',
session_id: activeSessionId,
data: b64,
});
}
// ── Input bar (mobile/tablet) ──────────────
// Send characters immediately, bypassing IME composition buffering.
// Clearing value on each input event cancels any active composition.
mobileInput.addEventListener('input', () => {
const val = mobileInput.value;
if (val) {
sendTerminalInput(val);
mobileInput.value = '';
}
});
// Catch Enter in the input field itself
mobileInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = mobileInput.value;
if (val) {
sendTerminalInput(val);
mobileInput.value = '';
}
sendTerminalInput('\r');
} else if (e.key === 'Tab') {
e.preventDefault();
sendTerminalInput('\t');
}
});
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
// ── Scroll to bottom ──────────────────────
function updateScrollButton() {
if (!activeSessionId || !sessions[activeSessionId]) {
scrollBottomBtn.classList.remove('visible');
return;
}
const term = sessions[activeSessionId].term;
const isAtBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
scrollBottomBtn.classList.toggle('visible', !isAtBottom);
}
scrollBottomBtn.onclick = () => {
if (activeSessionId && sessions[activeSessionId]) {
sessions[activeSessionId].term.scrollToBottom();
scrollBottomBtn.classList.remove('visible');
}
};
// ── Event listeners ────────────────────────
btnClaude.onclick = () => openSession('claude');
btnBash.onclick = () => openSession('bash');
// ── Init ───────────────────────────────────
connect();
})();
</script>
</body>
</html>