diff --git a/README.md b/README.md index 7f6ae4a..dec5685 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,12 @@ Structured JSON-lines, one entry per login attempt: | `Alt+T` | New tab | | `Alt+W` | Close tab | | `Alt+Shift+←/→` | Previous / next tab | -| `Alt+\` | Split pane left/right | -| `Alt+-` | Split pane top/bottom | +| `Alt+H` | Split pane left/right | +| `Alt+V` | Split pane top/bottom | | `Alt+X` | Close active pane | | `Ctrl+Shift+C` | Copy selection | | `Ctrl+V` | Paste | +| `Ctrl+←/→` | Word backward / forward (shell readline) | --- diff --git a/internals/session.go b/internals/session.go index 5d31df9..5ddd8b8 100644 --- a/internals/session.go +++ b/internals/session.go @@ -3,6 +3,7 @@ package internals import ( "crypto/rand" "fmt" + "os" "os/exec" "runtime" "strings" @@ -68,6 +69,19 @@ func getOrCreate(id string) *Session { } cmd.Dir = initialCwd + // Build environment: inherit parent env but force TERM so that bash readline + // correctly decodes modifier+cursor sequences (Shift+Arrow etc.). + // Without this, PTYs started from daemons/services may have TERM unset or + // set to "dumb", causing escape sequences to appear as literal characters. + env := os.Environ() + filtered := make([]string, 0, len(env)+1) + for _, e := range env { + if !strings.HasPrefix(e, "TERM=") { + filtered = append(filtered, e) + } + } + cmd.Env = append(filtered, "TERM=xterm-256color") + ptty, err := pty.Start(cmd) if err != nil { return nil diff --git a/internals/web/app.css b/internals/web/app.css index 180e6a8..38b12a4 100644 --- a/internals/web/app.css +++ b/internals/web/app.css @@ -149,6 +149,9 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0; .tb-btn.tb-danger { background: rgba(239,68,68,0.08); color: #f87171; border: 1px solid rgba(239,68,68,0.15); } .tb-btn.tb-danger:hover { background: rgba(239,68,68,0.16); color: #fca5a5; } +.tb-btn.tb-info { background: rgba(59,130,246,0.1); color: #93c5fd; + border: 1px solid rgba(59,130,246,0.2); } +.tb-btn.tb-info:hover { background: rgba(59,130,246,0.18); color: #bfdbfe; } .tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; } /* ── Toast ── */ @@ -258,6 +261,25 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0; .m-btn.busy .btn-text { display: none; } @keyframes spin { to { transform: rotate(360deg); } } +/* ── Info modal ── */ +.info-section-label { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; + color: #6ee7b7; font-weight: 700; margin: 14px 0 6px; +} +.info-section-label:first-child { margin-top: 0; } +.info-table { + width: 100%; border-collapse: collapse; font-size: 12px; +} +.info-table tr { border-bottom: 1px solid rgba(255,255,255,0.04); } +.info-table tr:last-child { border-bottom: none; } +.info-table td { padding: 5px 4px; color: #9ca3af; vertical-align: top; } +.info-table td:first-child { white-space: nowrap; padding-right: 16px; color: #e2e8f0; } +kbd { + display: inline-block; padding: 1px 6px; + background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12); + border-radius: 4px; font-size: 11px; font-family: inherit; color: #e2e8f0; +} + /* ── Auth modal specifics ── */ .auth-card { padding: 40px 36px; } .auth-logo { font-size: 22px; font-weight: 700; color: #6ee7b7; diff --git a/internals/web/app.js b/internals/web/app.js index 0717d00..d17a317 100644 --- a/internals/web/app.js +++ b/internals/web/app.js @@ -8,11 +8,10 @@ const TERM_THEME = { }; // ── Global state ────────────────────────────────────────────────────── -let tabs = []; -let activeTab = null; -let tabCounter = 0; -let isAuthenticated = AUTHED; // updated to true after successful login -let saveTimer = null; +let tabs = []; +let activeTab = null; +let tabCounter = 0; +let saveTimer = null; // ── Helpers ─────────────────────────────────────────────────────────── function randHexClient(n) { @@ -48,7 +47,7 @@ function saveWorkspace() { } async function _doSaveWorkspace() { - if (!isAuthenticated || !tabs.length) return; + if (!tabs.length) return; const ws = { id: WORKSPACE_ID, tabs: tabs.map(t => ({ @@ -138,12 +137,11 @@ document.addEventListener('keydown', function(e) { case 'KeyT': e.preventDefault(); newTab(); return; case 'KeyW': e.preventDefault(); if (activeTab) closeTab(activeTab); return; case 'KeyX': e.preventDefault(); closePane(); return; - case 'Backslash': e.preventDefault(); splitPane('h'); return; - case 'Minus': e.preventDefault(); splitPane('v'); return; + case 'KeyH': e.preventDefault(); splitPane('h'); return; // Alt+H → side-by-side + case 'KeyV': e.preventDefault(); splitPane('v'); return; // Alt+V → top/bottom case 'ArrowLeft': - e.preventDefault(); sendToActive('\x1bb'); return; case 'ArrowRight': - e.preventDefault(); sendToActive('\x1bf'); return; + return; // let xterm pass Ctrl+Arrow to shell readline } } else { if (e.code === 'ArrowLeft') { @@ -161,10 +159,9 @@ document.addEventListener('keydown', function(e) { } } - // Block ALL Ctrl+key browser shortcuts; xterm still gets the event. + // Block browser Ctrl+key defaults (find, save, etc.); xterm still gets event. if (C && !A && !M) { e.preventDefault(); - if (!S && e.code === 'KeyW') sendToActive('\x17'); return; } @@ -234,68 +231,12 @@ function bgClose(evt, id) { } document.addEventListener('keydown', e => { if (e.key === 'Escape') { - ['upOverlay', 'dlOverlay'].forEach(id => { + ['upOverlay', 'dlOverlay', 'infoOverlay'].forEach(id => { if (!document.getElementById(id).classList.contains('hidden')) closeModal(id); }); } }); -// ── Auth ─────────────────────────────────────────────────────────────── -async function doAuth() { - const username = document.getElementById('fUser').value.trim(); - const password = document.getElementById('fPass').value; - const btn = document.getElementById('authBtn'); - const errDiv = document.getElementById('authErr'); - - if (!username || !password) { showAuthErr('Please enter username and password'); return; } - - btn.disabled = true; btn.classList.add('busy'); - errDiv.classList.remove('show'); - - const form = new URLSearchParams(); - form.append('username', username); - form.append('password', password); - - try { - const res = await fetch('/auth', { method: 'POST', body: form, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); - const data = await res.json(); - if (data.ok) { - isAuthenticated = true; - document.getElementById('authOverlay').classList.add('hidden'); - // Load or create workspace after login - const ws = await loadWorkspace(); - if (ws && ws.tabs && ws.tabs.length) { - restoreWorkspace(ws); - } else { - newTab(); - } - const leaf = activeTab && activeTab.activeLeaf; - if (leaf) leaf.term.focus(); - } else { - showAuthErr(data.error || 'Authentication failed'); - } - } catch (_) { - showAuthErr('Network error — try again'); - } finally { - btn.disabled = false; btn.classList.remove('busy'); - } -} - -function showAuthErr(msg) { - const e = document.getElementById('authErr'); - e.textContent = msg; e.classList.add('show'); - document.getElementById('fPass').value = ''; - document.getElementById('fPass').focus(); -} - -document.getElementById('fUser').addEventListener('keydown', e => { - if (e.key === 'Enter') document.getElementById('fPass').focus(); -}); -document.getElementById('fPass').addEventListener('keydown', e => { - if (e.key === 'Enter') doAuth(); -}); - // ══════════════════════════════════════════════════════════════════════ // PANE TREE // ══════════════════════════════════════════════════════════════════════ @@ -394,15 +335,23 @@ function createLeaf(sessionId) { term.attachCustomKeyEventHandler(evt => { if (evt.type !== 'keydown') return true; const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey; + + // Copy selection if (C && S && evt.code === 'KeyC') { const sel = term.getSelection(); if (sel) navigator.clipboard.writeText(sel).then( () => toast('Copied!', 'ok'), () => toast('Copy failed', 'err')); return false; } + // Paste if (C && evt.code === 'KeyV') { pasteToTerminal(); return false; } - if (C && !S && !A && evt.code === 'KeyW') return false; - if (C && S && evt.code === 'KeyW') return false; + + // Block app-level Alt shortcuts from reaching the PTY + if (A && !C) { + if (!S && ['KeyT','KeyW','KeyX','KeyH','KeyV'].includes(evt.code)) return false; + if ( S && (evt.code === 'ArrowLeft' || evt.code === 'ArrowRight')) return false; + } + return true; }); @@ -440,7 +389,7 @@ function splitPane(dir) { applyFlex(splitNode); setupDivider(splitNode); - if (isAuthenticated) connectLeaf(newLeaf); + connectLeaf(newLeaf); setActiveLeaf(tab, newLeaf); refreshActiveHighlight(tab); @@ -592,7 +541,7 @@ function newTab(tabId, sessionId) { addTabButton(tab); switchTab(tab); saveWorkspace(); - if (isAuthenticated) connectLeaf(rootLeaf); + connectLeaf(rootLeaf); return tab; } @@ -618,9 +567,7 @@ function restoreTab(tabData) { tabs.push(tab); addTabButton(tab); - if (isAuthenticated) { - walkLeaves(root, l => connectLeaf(l)); - } + walkLeaves(root, l => connectLeaf(l)); return tab; } @@ -762,16 +709,13 @@ function modalFb(id, msg, type) { } // ── Bootstrap ────────────────────────────────────────────────────────── +// Terminal page is only served to authenticated users (server redirects +// unauthenticated requests to /login). Load saved workspace or start fresh. (async function bootstrap() { - if (isAuthenticated) { - document.getElementById('authOverlay').classList.add('hidden'); - const ws = await loadWorkspace(); - if (ws && ws.tabs && ws.tabs.length) { - restoreWorkspace(ws); - } else { - newTab(); - } + const ws = await loadWorkspace(); + if (ws && ws.tabs && ws.tabs.length) { + restoreWorkspace(ws); } else { - setTimeout(() => document.getElementById('fUser').focus(), 80); + newTab(); } })(); diff --git a/internals/web/shell.html b/internals/web/shell.html index 6911248..eb2b7b3 100644 --- a/internals/web/shell.html +++ b/internals/web/shell.html @@ -12,31 +12,6 @@
- - - + + +