feat: phone layout for the panel, and Termux-style extra keys in the terminal

This commit is contained in:
redtea committed 2026-10-14 14:43:06 +03:00
1 parent 81a2c96e0b
commit d456ffe757
5 files changed
+193 -23

No files matched your search

+126 -3
View File
@@ -9,6 +9,24 @@
<style>
#status.st-ok { color:#37c871; }
#status.st-err { color:#ff5c5c; }
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
with a row of extra keys at the bottom, the way Termux does it. */
.keys { display:none; }
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
body.touch #terminal { flex:1; min-width:0; height:auto; }
body.touch .keys {
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
}
.keys button {
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
}
.keys button:active { background:var(--border); }
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
</style>
</head>
<body>
@@ -26,6 +44,22 @@
<div class="wrap" style="max-width:none">
<div id="terminal"></div>
</div>
<div class="keys" id="keys">
<button type="button" data-k="esc">ESC</button>
<button type="button" data-k="/">/</button>
<button type="button" data-k="-">-</button>
<button type="button" data-k="home">HOME</button>
<button type="button" data-k="up">↑</button>
<button type="button" data-k="end">END</button>
<button type="button" data-k="pgup">PGUP</button>
<button type="button" data-k="tab">TAB</button>
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
<button type="button" data-k="alt" data-mod="alt">ALT</button>
<button type="button" data-k="left">←</button>
<button type="button" data-k="down">↓</button>
<button type="button" data-k="right">→</button>
<button type="button" data-k="pgdn">PGDN</button>
</div>
<script src="/static/xterm.js"></script>
<script src="/static/addon-fit.js"></script>
@@ -33,11 +67,13 @@
(function () {
const agentID = {{.Agent.ID}};
const statusEl = document.getElementById('status');
const touch = window.matchMedia('(pointer: coarse)').matches;
if (touch) document.body.classList.add('touch');
function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; }
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontSize: touch ? 13 : 14,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
theme: { background: '#0c0f13' }
});
@@ -126,17 +162,104 @@
term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
}
term.onData(function (data) {
function send(data) {
if (closed || !sid) return;
const bytes = new TextEncoder().encode(data);
post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) });
}
// What the keyboard types gets the CTRL / ALT of the extra keys, if armed.
term.onData(function (data) { send(withMods(data)); });
// --- Extra keys (touch screens) ---
// CTRL and ALT: a tap arms the key for the next key press, a long press
// keeps it on until tapped again.
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
const modBtn = {};
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
function showMods() {
for (const m in mods) {
modBtn[m].classList.toggle('on', mods[m] === 1);
modBtn[m].classList.toggle('lock', mods[m] === 2);
}
}
function useMods() {
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
showMods();
return used;
}
function ctrlChar(c) {
if (c === ' ' || c === '@') return '\x00';
if (c === '?') return '\x7f';
const code = c.toUpperCase().charCodeAt(0);
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
}
function withMods(data) {
if (!mods.ctrl && !mods.alt) return data;
const m = useMods();
if (m.ctrl && data.length === 1) data = ctrlChar(data);
return m.alt ? '\x1b' + data : data;
}
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
const TILDE = { pgup: 5, pgdn: 6 };
function keySeq(k) {
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
if (CSI[k]) {
if (p > 1) return '\x1b[1;' + p + CSI[k];
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
}
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
if (m.ctrl && s.length === 1) s = ctrlChar(s);
return m.alt ? '\x1b' + s : s;
}
let press = null;
document.querySelectorAll('#keys button').forEach(function (b) {
// Keep the focus (and the on-screen keyboard) on the terminal.
b.addEventListener('pointerdown', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
});
b.addEventListener('pointerup', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) {
if (press && press.mod === mod) {
clearTimeout(press.timer);
press = null;
mods[mod] = mods[mod] ? 0 : 1;
showMods();
}
return;
}
send(keySeq(b.dataset.k));
});
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
});
function sendResize() {
if (closed || !sid) return;
post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows });
}
window.addEventListener('resize', function () { fit.fit(); sendResize(); });
function refit() {
if (touch && window.visualViewport) {
// Follow the visible area, so the extra keys sit right above the
// on-screen keyboard.
const vv = window.visualViewport;
document.body.style.top = vv.offsetTop + 'px';
document.body.style.height = vv.height + 'px';
}
fit.fit();
}
window.addEventListener('resize', refit);
if (touch && window.visualViewport) {
window.visualViewport.addEventListener('resize', refit);
window.visualViewport.addEventListener('scroll', refit);
}
refit();
term.onResize(sendResize);
window.addEventListener('pagehide', function () {