Compare commits
5
Commits
v0.3.3
...
301acca6da
No files matched your search
Generated
+6
-6
@@ -3624,7 +3624,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3645,7 +3645,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3670,7 +3670,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-control-plane"
|
name = "wakey-control-plane"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3699,7 +3699,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"macaddr",
|
"macaddr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3711,7 +3711,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-linux"
|
name = "wakey-linux"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -3728,7 +3728,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wakey-vt100"
|
name = "wakey-vt100"
|
||||||
version = "0.16.2-wakey.1"
|
version = "0.16.2-wakey.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey"
|
name = "wakey"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = [
|
||||||
|
# "brotli==1.2.0",
|
||||||
|
# "fonttools==4.63.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""Rebuild Wakey's self-hosted terminal webfont from Nerd Fonts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
|
||||||
|
NERD_FONTS_VERSION = "v3.4.0"
|
||||||
|
ARCHIVE_SHA256 = "ef552a3e638f25125c6ad4c51176a6adcdce295ab1d2ffacf0db060caf8c1582"
|
||||||
|
WOFF2_SHA256 = "50680a64466fbcc3eff68e00f84505f55681c86f8450c042416af5a8dbc1cce1"
|
||||||
|
FONT_FILE = "JetBrainsMonoNerdFontMono-Regular"
|
||||||
|
ARCHIVE_URL = (
|
||||||
|
"https://github.com/ryanoasis/nerd-fonts/releases/download/"
|
||||||
|
f"{NERD_FONTS_VERSION}/JetBrainsMono.tar.xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUTPUT_DIR = ROOT / "ui/src/assets/fonts/jetbrains-mono-nerd"
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_sha256(path: Path, expected: str) -> None:
|
||||||
|
actual = sha256(path)
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"checksum mismatch for {path}\nexpected: {expected}\nactual: {actual}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download(url: str, destination: Path) -> None:
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": "wakey-font-preparer"})
|
||||||
|
with (
|
||||||
|
urllib.request.urlopen(request, timeout=60) as response,
|
||||||
|
destination.open("wb") as output,
|
||||||
|
):
|
||||||
|
shutil.copyfileobj(response, output)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_member(archive: tarfile.TarFile, name: str, destination: Path) -> None:
|
||||||
|
source = archive.extractfile(name)
|
||||||
|
if source is None:
|
||||||
|
raise RuntimeError(f"missing {name} in Nerd Fonts archive")
|
||||||
|
with source, destination.open("wb") as output:
|
||||||
|
shutil.copyfileobj(source, output)
|
||||||
|
|
||||||
|
|
||||||
|
def install_file(source: Path, destination: Path) -> None:
|
||||||
|
"""Atomically replace an asset without exposing a partial file to Vite."""
|
||||||
|
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||||
|
try:
|
||||||
|
shutil.copyfile(source, temporary)
|
||||||
|
os.chmod(temporary, 0o644)
|
||||||
|
temporary.replace(destination)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="wakey-terminal-font.") as temp:
|
||||||
|
work_dir = Path(temp)
|
||||||
|
archive_path = work_dir / "JetBrainsMono.tar.xz"
|
||||||
|
ttf_path = work_dir / f"{FONT_FILE}.ttf"
|
||||||
|
woff2_path = work_dir / f"{FONT_FILE}.woff2"
|
||||||
|
license_path = work_dir / "OFL.txt"
|
||||||
|
|
||||||
|
print(f"Downloading Nerd Fonts {NERD_FONTS_VERSION} JetBrainsMono archive")
|
||||||
|
download(ARCHIVE_URL, archive_path)
|
||||||
|
verify_sha256(archive_path, ARCHIVE_SHA256)
|
||||||
|
|
||||||
|
with tarfile.open(archive_path, "r:xz") as archive:
|
||||||
|
extract_member(archive, ttf_path.name, ttf_path)
|
||||||
|
extract_member(archive, license_path.name, license_path)
|
||||||
|
|
||||||
|
# FontTools otherwise stamps the current time into the head table,
|
||||||
|
# making equivalent rebuilds produce different WOFF2 files.
|
||||||
|
font = TTFont(ttf_path, recalcTimestamp=False)
|
||||||
|
font.flavor = "woff2"
|
||||||
|
font.save(woff2_path)
|
||||||
|
verify_sha256(woff2_path, WOFF2_SHA256)
|
||||||
|
|
||||||
|
install_file(woff2_path, OUTPUT_DIR / woff2_path.name)
|
||||||
|
install_file(license_path, OUTPUT_DIR / license_path.name)
|
||||||
|
|
||||||
|
print(f"Prepared {OUTPUT_DIR / f'{FONT_FILE}.woff2'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+4
-1
@@ -2,7 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
|
||||||
|
/>
|
||||||
<title>Wakey Operator UI</title>
|
<title>Wakey Operator UI</title>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
+7
-6
@@ -15,12 +15,13 @@
|
|||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@xterm/addon-clipboard": "^0.2.0",
|
"@xterm/addon-clipboard": "0.3.0-beta.290",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "0.12.0-beta.290",
|
||||||
"@xterm/addon-unicode-graphemes": "^0.4.0",
|
"@xterm/addon-image": "0.10.0-beta.290",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode-graphemes": "0.5.0-beta.290",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-unicode11": "0.10.0-beta.290",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/addon-web-links": "0.13.0-beta.290",
|
||||||
|
"@xterm/xterm": "6.1.0-beta.290",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
|||||||
Generated
+61
-36
@@ -18,23 +18,26 @@ importers:
|
|||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2([email protected](@types/[email protected])([email protected]))
|
version: 4.2.2([email protected](@types/[email protected])([email protected]))
|
||||||
'@xterm/addon-clipboard':
|
'@xterm/addon-clipboard':
|
||||||
specifier: ^0.2.0
|
specifier: 0.3.0-beta.290
|
||||||
version: 0.2.0
|
version: 0.3.0-beta.290(@xterm/[email protected])
|
||||||
'@xterm/addon-fit':
|
'@xterm/addon-fit':
|
||||||
specifier: ^0.11.0
|
specifier: 0.12.0-beta.290
|
||||||
version: 0.11.0
|
version: 0.12.0-beta.290(@xterm/[email protected])
|
||||||
|
'@xterm/addon-image':
|
||||||
|
specifier: 0.10.0-beta.290
|
||||||
|
version: 0.10.0-beta.290(@xterm/[email protected])
|
||||||
'@xterm/addon-unicode-graphemes':
|
'@xterm/addon-unicode-graphemes':
|
||||||
specifier: ^0.4.0
|
specifier: 0.5.0-beta.290
|
||||||
version: 0.4.0
|
version: 0.5.0-beta.290(@xterm/[email protected])
|
||||||
'@xterm/addon-unicode11':
|
'@xterm/addon-unicode11':
|
||||||
specifier: ^0.9.0
|
specifier: 0.10.0-beta.290
|
||||||
version: 0.9.0
|
version: 0.10.0-beta.290(@xterm/[email protected])
|
||||||
'@xterm/addon-web-links':
|
'@xterm/addon-web-links':
|
||||||
specifier: ^0.12.0
|
specifier: 0.13.0-beta.290
|
||||||
version: 0.12.0
|
version: 0.13.0-beta.290(@xterm/[email protected])
|
||||||
'@xterm/xterm':
|
'@xterm/xterm':
|
||||||
specifier: ^6.0.0
|
specifier: 6.1.0-beta.290
|
||||||
version: 6.0.0
|
version: 6.1.0-beta.290
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -833,23 +836,38 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
|
||||||
'@xterm/addon-clipboard@0.2.0':
|
'@xterm/addon-clipboard@0.3.0-beta.290':
|
||||||
resolution: {integrity: sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==}
|
resolution: {integrity: sha512-ky44+eNKIxL9clMgrWCBRSRe1Y6Oj+ayNavySfFioScxpuyicu/GYaF7n97eqW3FW0eClElCtsK+wJ6AGqnJmw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/[email protected]1.0':
|
'@xterm/[email protected]2.0-beta.290':
|
||||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
resolution: {integrity: sha512-mJ77s82VhARA8XhuWcUnWYKAZkEDzSnCzgBrT9qjHJlHWr+G5duDZcOBnxnVVNtp21LDKaONr3c7IxuLii1XNg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/addon-[email protected].0':
|
'@xterm/addon-[email protected]0':
|
||||||
resolution: {integrity: sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==}
|
resolution: {integrity: sha512-gzQfH3tACsdf52n0lY3QMwLY7HcII1ce+IvzphWs9UucQIZ3rBKjEkYz4sxBhhEP1Eb6dNn1uerrfoJECmcyGg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/addon-unicode[email protected].0':
|
'@xterm/addon-unicode[email protected]0':
|
||||||
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
|
resolution: {integrity: sha512-c0hF4HJrbZUlb1cVXNldl6YRf+Udoe3nc80djNnxSzjHgmryT09DhL4U4IDcCqSkzgwMENnN65mexlYKyNXNBw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/addon-[email protected].0':
|
'@xterm/addon-[email protected]0':
|
||||||
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
resolution: {integrity: sha512-tSmvf6hX1tT4RAgqbC8moZc/2oLR3qkaAL0xHaSMPd5bch2wkSvjfTxDfb3cfXJLyKCkWR+uwH54JYNOnCTW5g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/[email protected].0':
|
'@xterm/[email protected]0':
|
||||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
resolution: {integrity: sha512-p0OL4dPrmETt+6sFUniSjiV2g5nV35ckipVZQv1uuZ8GKtHYvlDTJj4Fr+cHzrsNmDhceaaiIW6fMPynJtCm1w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@xterm/xterm': ^6.1.0-beta.290
|
||||||
|
|
||||||
|
'@xterm/[email protected]':
|
||||||
|
resolution: {integrity: sha512-lbzQN1cx0h9uLUWQM7l6HxjP0qPMoqhc1ahlwmqgbSALpTeOUAQfMhMhHr0TSGRIcv7S4grvhhzQCb6teFrZ7Q==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
@@ -1439,9 +1457,6 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -2847,19 +2862,31 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@xterm/addon-clipboard@0.2.0':
|
'@xterm/addon-clipboard@0.3.0-beta.290(@xterm/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
js-base64: 3.8.1
|
'@xterm/xterm': 6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/[email protected]1.0': {}
|
'@xterm/[email protected]2.0-beta.290(@xterm/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/addon-[email protected]': {}
|
'@xterm/addon-[email protected](@xterm/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/addon-unicode[email protected]': {}
|
'@xterm/addon-unicode[email protected](@xterm/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/addon-[email protected]': {}
|
'@xterm/addon-[email protected](@xterm/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 6.1.0-beta.290
|
||||||
|
|
||||||
'@xterm/[email protected]': {}
|
'@xterm/[email protected](@xterm/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@xterm/xterm': 6.1.0-beta.290
|
||||||
|
|
||||||
|
'@xterm/[email protected]': {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3403,8 +3430,6 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# JetBrainsMono Nerd Font Mono
|
||||||
|
|
||||||
|
`JetBrainsMonoNerdFontMono-Regular.woff2` is the regular monospaced variant
|
||||||
|
from Nerd Fonts v3.4.0 (JetBrains Mono v2.304), converted from the release TTF
|
||||||
|
to WOFF2 without subsetting.
|
||||||
|
|
||||||
|
Source archive:
|
||||||
|
https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/JetBrainsMono.tar.xz
|
||||||
|
|
||||||
|
The font is distributed under the SIL Open Font License in `OFL.txt`.
|
||||||
|
|
||||||
|
Rebuild the checked-in webfont with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/prepare_terminal_font.py
|
||||||
|
```
|
||||||
+323
-208
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { ImageAddon } from "@xterm/addon-image";
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { Terminal as XTerm } from "@xterm/xterm";
|
import { Terminal as XTerm } from "@xterm/xterm";
|
||||||
import { Eraser, Plus, RotateCcw, Terminal, X } from "lucide-react";
|
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,24 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
MAX_TERMINAL_FONT_SIZE,
|
||||||
|
MIN_TERMINAL_FONT_SIZE,
|
||||||
|
TerminalAccessoryBar,
|
||||||
|
} from "@/pages/terminal/TerminalAccessoryBar";
|
||||||
|
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
|
||||||
|
import {
|
||||||
|
NO_TERMINAL_MODIFIERS,
|
||||||
|
applyTerminalModifiers,
|
||||||
|
mergeTerminalSession,
|
||||||
|
orderTerminalSessions,
|
||||||
|
reconcileTerminalSessions,
|
||||||
|
restoreCandidates,
|
||||||
|
visibleTerminalText,
|
||||||
|
type TerminalConnectionState,
|
||||||
|
type TerminalModifiers,
|
||||||
|
} from "@/pages/terminal/sessionUtils";
|
||||||
|
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
@@ -32,15 +51,17 @@ type Props = {
|
|||||||
onSelectAgent: (agentId: string) => void;
|
onSelectAgent: (agentId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConnectionState =
|
|
||||||
| "idle"
|
|
||||||
| "connecting"
|
|
||||||
| "ready"
|
|
||||||
| "disconnected"
|
|
||||||
| "exited";
|
|
||||||
|
|
||||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||||
|
const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||||
|
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||||
|
|
||||||
|
type TerminalTouch = {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// polyfill for testing in browsers that don't support crypto.randomUUID()
|
// polyfill for testing in browsers that don't support crypto.randomUUID()
|
||||||
const thing = () =>
|
const thing = () =>
|
||||||
@@ -63,88 +84,17 @@ function terminalOperatorId(): string {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeTerminalSession(
|
|
||||||
sessions: TerminalSession[],
|
|
||||||
next: TerminalSession,
|
|
||||||
): TerminalSession[] {
|
|
||||||
const existingIndex = sessions.findIndex(
|
|
||||||
(item) => item.terminal_id === next.terminal_id,
|
|
||||||
);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
// Attaching updates session state, not identity or tab position. Removing
|
|
||||||
// and re-inserting here made same-second sessions swap places on click.
|
|
||||||
return sessions.map((item, index) =>
|
|
||||||
index === existingIndex ? next : item,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return orderTerminalSessions([...sessions, next]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareTerminalSessions(
|
|
||||||
left: TerminalSession,
|
|
||||||
right: TerminalSession,
|
|
||||||
): number {
|
|
||||||
return (
|
|
||||||
left.created_at_unix - right.created_at_unix ||
|
|
||||||
left.terminal_id.localeCompare(right.terminal_id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderTerminalSessions(sessions: TerminalSession[]): TerminalSession[] {
|
|
||||||
return [...sessions].sort(compareTerminalSessions);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconcileTerminalSessions(
|
|
||||||
current: TerminalSession[],
|
|
||||||
listed: TerminalSession[],
|
|
||||||
): TerminalSession[] {
|
|
||||||
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
|
||||||
const retained = current.flatMap((item) => {
|
|
||||||
const updated = listedById.get(item.terminal_id);
|
|
||||||
if (!updated) return [];
|
|
||||||
listedById.delete(item.terminal_id);
|
|
||||||
return [updated];
|
|
||||||
});
|
|
||||||
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreCandidates(
|
|
||||||
sessions: TerminalSession[],
|
|
||||||
rememberedId: string | null,
|
|
||||||
selectedAgentId: string,
|
|
||||||
): TerminalSession[] {
|
|
||||||
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
|
||||||
const available = sessions.filter(
|
|
||||||
(item) => !item.operator_attached && item !== remembered,
|
|
||||||
);
|
|
||||||
available.sort((left, right) => {
|
|
||||||
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
|
||||||
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
|
||||||
return (
|
|
||||||
rightPreferred - leftPreferred ||
|
|
||||||
right.created_at_unix - left.created_at_unix
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return remembered ? [remembered, ...available] : available;
|
|
||||||
}
|
|
||||||
|
|
||||||
function websocketUrl(path: string): string {
|
function websocketUrl(path: string): string {
|
||||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
return `${scheme}//${window.location.host}${path}`;
|
return `${scheme}//${window.location.host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalCreatedTime(createdAtUnix: number): string {
|
|
||||||
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalPage({
|
export function TerminalPage({
|
||||||
agents,
|
agents,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
onSelectAgent,
|
onSelectAgent,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const pageRef = useRef<HTMLElement>(null);
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const terminalRef = useRef<XTerm | null>(null);
|
const terminalRef = useRef<XTerm | null>(null);
|
||||||
const fitRef = useRef<FitAddon | null>(null);
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
@@ -152,14 +102,36 @@ export function TerminalPage({
|
|||||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||||
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
|
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
|
||||||
|
const modifiersRef = useRef<TerminalModifiers>(NO_TERMINAL_MODIFIERS);
|
||||||
|
const terminalTouchRef = useRef<TerminalTouch | null>(null);
|
||||||
|
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
|
||||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||||
const [connection, setConnection] = useState<ConnectionState>("idle");
|
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
|
||||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
const [terminalFontFamily, setTerminalFontFamily] = useState<string>();
|
||||||
|
const [terminalFontSize, setTerminalFontSize] = useState(
|
||||||
|
DEFAULT_TERMINAL_FONT_SIZE,
|
||||||
|
);
|
||||||
|
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
|
||||||
|
const [modifiers, setModifiers] = useState<TerminalModifiers>(
|
||||||
|
NO_TERMINAL_MODIFIERS,
|
||||||
|
);
|
||||||
|
const [accessoriesOpen, setAccessoriesOpen] = useState(
|
||||||
|
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
|
||||||
|
);
|
||||||
const [operatorId] = useState(terminalOperatorId);
|
const [operatorId] = useState(terminalOperatorId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clock = window.setInterval(
|
||||||
|
() => setNowUnix(Math.floor(Date.now() / 1000)),
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(clock);
|
||||||
|
}, []);
|
||||||
|
|
||||||
activeSessionRef.current = session;
|
activeSessionRef.current = session;
|
||||||
selectedAgentIdRef.current = selectedAgentId;
|
selectedAgentIdRef.current = selectedAgentId;
|
||||||
|
|
||||||
@@ -175,10 +147,12 @@ export function TerminalPage({
|
|||||||
);
|
);
|
||||||
const agentAtSessionLimit =
|
const agentAtSessionLimit =
|
||||||
selectedAgentSessionCount >= selectedAgentSessionLimit;
|
selectedAgentSessionCount >= selectedAgentSessionLimit;
|
||||||
const canRequestStart =
|
const canRequestStart = Boolean(
|
||||||
selectedAgent?.connected &&
|
selectedAgent?.connected &&
|
||||||
selectedAgent.capabilities.includes("terminal") &&
|
selectedAgent.capabilities.includes("terminal") &&
|
||||||
connection !== "connecting";
|
connection !== "connecting",
|
||||||
|
);
|
||||||
|
const terminalInputReady = connection === "ready";
|
||||||
|
|
||||||
const detachTransport = useCallback(() => {
|
const detachTransport = useCallback(() => {
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
@@ -186,6 +160,101 @@ export function TerminalPage({
|
|||||||
socket?.close();
|
socket?.close();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const writeTerminalData = useCallback(
|
||||||
|
(data: string, consumeModifiers = false) => {
|
||||||
|
const socket = socketRef.current;
|
||||||
|
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||||
|
const activeModifiers = modifiersRef.current;
|
||||||
|
const output = consumeModifiers
|
||||||
|
? applyTerminalModifiers(data, activeModifiers)
|
||||||
|
: data;
|
||||||
|
socket.send(new TextEncoder().encode(output));
|
||||||
|
if (consumeModifiers && (activeModifiers.ctrl || activeModifiers.meta)) {
|
||||||
|
modifiersRef.current = NO_TERMINAL_MODIFIERS;
|
||||||
|
setModifiers(NO_TERMINAL_MODIFIERS);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleModifier = useCallback((modifier: keyof TerminalModifiers) => {
|
||||||
|
setModifiers((current) => {
|
||||||
|
const next = { ...current, [modifier]: !current[modifier] };
|
||||||
|
modifiersRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
window.requestAnimationFrame(() => terminalRef.current?.focus());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyTerminalText = useCallback(async () => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) return;
|
||||||
|
const text = terminal.hasSelection()
|
||||||
|
? terminal.getSelection()
|
||||||
|
: visibleTerminalText(terminal);
|
||||||
|
if (!text) {
|
||||||
|
toast.info("Nothing to copy");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(
|
||||||
|
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Clipboard access was denied", {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pasteTerminalText = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (text) writeTerminalData(text);
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Clipboard access was denied", {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [writeTerminalData]);
|
||||||
|
|
||||||
|
const writeAccessoryKey = useCallback(
|
||||||
|
(data: string) => {
|
||||||
|
writeTerminalData(data);
|
||||||
|
window.requestAnimationFrame(() => terminalRef.current?.focus());
|
||||||
|
},
|
||||||
|
[writeTerminalData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleTerminalKeyboard = useCallback(() => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) return;
|
||||||
|
const blurTerminalInput = () => {
|
||||||
|
terminal.textarea?.blur();
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (
|
||||||
|
activeElement instanceof HTMLElement &&
|
||||||
|
hostRef.current?.contains(activeElement)
|
||||||
|
) {
|
||||||
|
activeElement.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const shouldHide =
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current ||
|
||||||
|
Boolean(hostRef.current?.contains(document.activeElement));
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current = false;
|
||||||
|
if (shouldHide) {
|
||||||
|
blurTerminalInput();
|
||||||
|
// Base UI may restore the previously focused element as its pointer
|
||||||
|
// interaction completes. Dismiss the soft keyboard after that cycle too.
|
||||||
|
window.requestAnimationFrame(blurTerminalInput);
|
||||||
|
} else {
|
||||||
|
terminal.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const sendResize = useCallback(() => {
|
const sendResize = useCallback(() => {
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
@@ -209,6 +278,26 @@ export function TerminalPage({
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const changeTerminalFontSize = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const terminal = terminalRef.current;
|
||||||
|
if (!terminal) return;
|
||||||
|
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
|
||||||
|
const next = Math.min(
|
||||||
|
MAX_TERMINAL_FONT_SIZE,
|
||||||
|
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
|
||||||
|
);
|
||||||
|
if (next === current) return;
|
||||||
|
terminal.options.fontSize = next;
|
||||||
|
setTerminalFontSize(next);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
fitRef.current?.fit();
|
||||||
|
sendResize();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendResize],
|
||||||
|
);
|
||||||
|
|
||||||
const connect = useCallback(
|
const connect = useCallback(
|
||||||
(nextSession: TerminalSession) => {
|
(nextSession: TerminalSession) => {
|
||||||
if (!nextSession.attachment_token) {
|
if (!nextSession.attachment_token) {
|
||||||
@@ -304,16 +393,46 @@ export function TerminalPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return;
|
let cancelled = false;
|
||||||
|
void loadTerminalFontFamily().then((fontFamily) => {
|
||||||
|
if (!cancelled) setTerminalFontFamily(fontFamily);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
const updateViewportHeight = () => {
|
||||||
|
const height = Math.round(viewport?.height ?? window.innerHeight);
|
||||||
|
pageRef.current?.style.setProperty(
|
||||||
|
"--terminal-visual-viewport-height",
|
||||||
|
`${height}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
updateViewportHeight();
|
||||||
|
viewport?.addEventListener("resize", updateViewportHeight);
|
||||||
|
viewport?.addEventListener("scroll", updateViewportHeight);
|
||||||
|
window.addEventListener("resize", updateViewportHeight);
|
||||||
|
return () => {
|
||||||
|
viewport?.removeEventListener("resize", updateViewportHeight);
|
||||||
|
viewport?.removeEventListener("scroll", updateViewportHeight);
|
||||||
|
window.removeEventListener("resize", updateViewportHeight);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current || !terminalFontFamily) return;
|
||||||
const terminal = new XTerm({
|
const terminal = new XTerm({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
fontFamily: terminalFontFamily,
|
||||||
fontSize: 14,
|
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
||||||
lineHeight: 1.1,
|
lineHeight: 1.1,
|
||||||
// xterm uses this width for its internal scrollbar as well as the
|
// xterm uses this width for its internal scrollbar as well as the
|
||||||
// overview ruler. Reserve a larger touch target on coarse pointers.
|
// overview ruler. Reserve a larger touch target on coarse pointers.
|
||||||
overviewRuler: {
|
scrollbar: {
|
||||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||||
},
|
},
|
||||||
scrollback: 5000,
|
scrollback: 5000,
|
||||||
@@ -331,6 +450,16 @@ export function TerminalPage({
|
|||||||
terminal.unicode.activeVersion = "11";
|
terminal.unicode.activeVersion = "11";
|
||||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||||
terminal.loadAddon(new WebLinksAddon());
|
terminal.loadAddon(new WebLinksAddon());
|
||||||
|
terminal.loadAddon(
|
||||||
|
new ImageAddon({
|
||||||
|
// // Keep image terminals useful on phones without accepting the addon's
|
||||||
|
// // much larger default decode and retained-canvas memory ceilings.
|
||||||
|
// pixelLimit: 4 * 1024 * 1024,
|
||||||
|
// sixelSizeLimit: 8 * 1024 * 1024,
|
||||||
|
// iipSizeLimit: 8 * 1024 * 1024,
|
||||||
|
// storageLimit: 32,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
terminal.loadAddon(new ClipboardAddon());
|
terminal.loadAddon(new ClipboardAddon());
|
||||||
terminal.loadAddon(fit);
|
terminal.loadAddon(fit);
|
||||||
@@ -425,19 +554,7 @@ export function TerminalPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void document.fonts.ready.then(() => {
|
const input = terminal.onData((data) => writeTerminalData(data, true));
|
||||||
if (cancelled) return;
|
|
||||||
fit.fit();
|
|
||||||
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
|
||||||
sendResize();
|
|
||||||
});
|
|
||||||
|
|
||||||
const input = terminal.onData((data) => {
|
|
||||||
const socket = socketRef.current;
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send(new TextEncoder().encode(data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let resizeFrame = 0;
|
let resizeFrame = 0;
|
||||||
let lastHostWidth = 0;
|
let lastHostWidth = 0;
|
||||||
let lastHostHeight = 0;
|
let lastHostHeight = 0;
|
||||||
@@ -482,7 +599,13 @@ export function TerminalPage({
|
|||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
fitRef.current = null;
|
fitRef.current = null;
|
||||||
};
|
};
|
||||||
}, [connect, detachTransport, sendResize]);
|
}, [
|
||||||
|
connect,
|
||||||
|
detachTransport,
|
||||||
|
sendResize,
|
||||||
|
terminalFontFamily,
|
||||||
|
writeTerminalData,
|
||||||
|
]);
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
if (!selectedAgentId || !terminalRef.current) return;
|
if (!selectedAgentId || !terminalRef.current) return;
|
||||||
@@ -628,7 +751,11 @@ export function TerminalPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="terminal-page" aria-label="Remote terminal">
|
<section
|
||||||
|
className="terminal-page"
|
||||||
|
aria-label="Remote terminal"
|
||||||
|
ref={pageRef}
|
||||||
|
>
|
||||||
<header className="terminal-toolbar">
|
<header className="terminal-toolbar">
|
||||||
<div className="terminal-heading">
|
<div className="terminal-heading">
|
||||||
<Terminal className="size-5 text-primary" aria-hidden />
|
<Terminal className="size-5 text-primary" aria-hidden />
|
||||||
@@ -661,112 +788,20 @@ export function TerminalPage({
|
|||||||
|
|
||||||
<div className="terminal-frame">
|
<div className="terminal-frame">
|
||||||
<div className="terminal-framebar">
|
<div className="terminal-framebar">
|
||||||
<div className="terminal-tabs">
|
<TerminalSessionTabs
|
||||||
<div
|
agents={agents}
|
||||||
className="terminal-tab-list"
|
sessions={sessions}
|
||||||
role="tablist"
|
activeTerminalId={session?.terminal_id}
|
||||||
aria-label="Terminal sessions"
|
connection={connection}
|
||||||
>
|
sessionTitles={sessionTitles}
|
||||||
{sessions.length === 0 ? (
|
nowUnix={nowUnix}
|
||||||
<div className="terminal-session-label">
|
canRequestStart={canRequestStart}
|
||||||
<Terminal className="size-4" aria-hidden />
|
agentAtSessionLimit={agentAtSessionLimit}
|
||||||
<span>No active session</span>
|
selectedAgentSessionLimit={selectedAgentSessionLimit}
|
||||||
</div>
|
onActivate={(item) => void activateSession(item)}
|
||||||
) : (
|
onClose={(item) => void closeSession(item)}
|
||||||
sessions.map((item) => {
|
onStart={() => void start()}
|
||||||
const agent = agents.find(
|
|
||||||
(candidate) => candidate.agent_id === item.agent_id,
|
|
||||||
);
|
|
||||||
const active = item.terminal_id === session?.terminal_id;
|
|
||||||
const locked = item.operator_attached && !active;
|
|
||||||
const agentLabel = agent
|
|
||||||
? displayAgentLabel(agent)
|
|
||||||
: item.agent_id;
|
|
||||||
const sessionTitle = sessionTitles[item.terminal_id];
|
|
||||||
const tabLabel = sessionTitle
|
|
||||||
? `${agentLabel} · ${sessionTitle}`
|
|
||||||
: agentLabel;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.terminal_id}
|
|
||||||
role="presentation"
|
|
||||||
className="terminal-tab"
|
|
||||||
data-active={active || undefined}
|
|
||||||
data-locked={locked || undefined}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={active}
|
|
||||||
className="terminal-tab-select"
|
|
||||||
disabled={locked || connection === "connecting"}
|
|
||||||
title={
|
|
||||||
locked ? "Attached in another browser" : tabLabel
|
|
||||||
}
|
|
||||||
onClick={() => void activateSession(item)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="terminal-tab-dot"
|
|
||||||
data-state={
|
|
||||||
active
|
|
||||||
? connection
|
|
||||||
: locked
|
|
||||||
? "attached"
|
|
||||||
: item.agent_attached
|
|
||||||
? "detached"
|
|
||||||
: "disconnected"
|
|
||||||
}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
/>
|
||||||
<span className="terminal-tab-label">{tabLabel}</span>
|
|
||||||
<time
|
|
||||||
dateTime={new Date(
|
|
||||||
item.created_at_unix * 1000,
|
|
||||||
).toISOString()}
|
|
||||||
>
|
|
||||||
{terminalCreatedTime(item.created_at_unix)}
|
|
||||||
</time>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="terminal-tab-close"
|
|
||||||
disabled={locked}
|
|
||||||
aria-label={`Close ${agentLabel} terminal session`}
|
|
||||||
title={
|
|
||||||
locked
|
|
||||||
? "Attached in another browser"
|
|
||||||
: "Close session"
|
|
||||||
}
|
|
||||||
onClick={() => void closeSession(item)}
|
|
||||||
>
|
|
||||||
<X className="size-3.5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="terminal-new-tab"
|
|
||||||
disabled={!canRequestStart}
|
|
||||||
aria-label="New terminal session"
|
|
||||||
onClick={start}
|
|
||||||
>
|
|
||||||
<Plus className="size-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{agentAtSessionLimit
|
|
||||||
? `${selectedAgentSessionLimit}-session limit reached`
|
|
||||||
: "New session"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="terminal-frame-actions">
|
<div className="terminal-frame-actions">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -788,6 +823,27 @@ export function TerminalPage({
|
|||||||
Reconnect
|
Reconnect
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-accessories-toggle"
|
||||||
|
data-active={accessoriesOpen || undefined}
|
||||||
|
aria-label={
|
||||||
|
accessoriesOpen
|
||||||
|
? "Hide terminal controls"
|
||||||
|
: "Show terminal controls"
|
||||||
|
}
|
||||||
|
aria-expanded={accessoriesOpen}
|
||||||
|
onClick={() => setAccessoriesOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
<Keyboard className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Terminal controls</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button
|
<Button
|
||||||
@@ -805,7 +861,66 @@ export function TerminalPage({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-surface" ref={hostRef} />
|
{accessoriesOpen ? (
|
||||||
|
<TerminalAccessoryBar
|
||||||
|
inputReady={terminalInputReady}
|
||||||
|
modifiers={modifiers}
|
||||||
|
fontSize={terminalFontSize}
|
||||||
|
onRememberInputFocus={() => {
|
||||||
|
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||||
|
hostRef.current?.contains(document.activeElement),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onToggleModifier={toggleModifier}
|
||||||
|
onWriteKey={writeAccessoryKey}
|
||||||
|
onScrollPages={(pages) => terminalRef.current?.scrollPages(pages)}
|
||||||
|
onCopy={() => void copyTerminalText()}
|
||||||
|
onPaste={() => void pasteTerminalText()}
|
||||||
|
onToggleKeyboard={toggleTerminalKeyboard}
|
||||||
|
onChangeFontSize={changeTerminalFontSize}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="terminal-surface"
|
||||||
|
ref={hostRef}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.pointerType !== "touch") {
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalTouchRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
const touch = terminalTouchRef.current;
|
||||||
|
if (!touch || touch.pointerId !== event.pointerId || touch.moved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
touch.moved =
|
||||||
|
Math.hypot(
|
||||||
|
event.clientX - touch.startX,
|
||||||
|
event.clientY - touch.startY,
|
||||||
|
) > TERMINAL_TOUCH_SLOP_PX;
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
const touch = terminalTouchRef.current;
|
||||||
|
terminalTouchRef.current = null;
|
||||||
|
if (
|
||||||
|
touch?.pointerId === event.pointerId &&
|
||||||
|
!touch.moved &&
|
||||||
|
event.pointerType === "touch"
|
||||||
|
) {
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
terminalTouchRef.current = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
ClipboardCopy,
|
||||||
|
ClipboardPaste,
|
||||||
|
Keyboard,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { MouseEvent, PointerEvent } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { TerminalModifiers } from "@/pages/terminal/sessionUtils";
|
||||||
|
|
||||||
|
export const MIN_TERMINAL_FONT_SIZE = 10;
|
||||||
|
export const MAX_TERMINAL_FONT_SIZE = 22;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
inputReady: boolean;
|
||||||
|
modifiers: TerminalModifiers;
|
||||||
|
fontSize: number;
|
||||||
|
onRememberInputFocus: () => void;
|
||||||
|
onToggleModifier: (modifier: keyof TerminalModifiers) => void;
|
||||||
|
onWriteKey: (data: string) => void;
|
||||||
|
onScrollPages: (pages: number) => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
onPaste: () => void;
|
||||||
|
onToggleKeyboard: () => void;
|
||||||
|
onChangeFontSize: (delta: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTROL_KEYS: ReadonlyArray<
|
||||||
|
readonly [label: string, sequence: string, title: string]
|
||||||
|
> = [
|
||||||
|
["^C", "\x03", "Interrupt"],
|
||||||
|
["^U", "\x15", "Clear input line"],
|
||||||
|
["^A", "\x01", "Start of line"],
|
||||||
|
["^E", "\x05", "End of line"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const ARROW_KEYS: ReadonlyArray<
|
||||||
|
readonly [label: string, sequence: string, icon: LucideIcon]
|
||||||
|
> = [
|
||||||
|
["Left arrow", "\x1b[D", ArrowLeft],
|
||||||
|
["Down arrow", "\x1b[B", ArrowDown],
|
||||||
|
["Up arrow", "\x1b[A", ArrowUp],
|
||||||
|
["Right arrow", "\x1b[C", ArrowRight],
|
||||||
|
];
|
||||||
|
|
||||||
|
function preserveInputFocus(
|
||||||
|
event: PointerEvent<HTMLDivElement> | MouseEvent<HTMLDivElement>,
|
||||||
|
onRememberInputFocus: () => void,
|
||||||
|
) {
|
||||||
|
if (event.target instanceof Element && event.target.closest("button")) {
|
||||||
|
onRememberInputFocus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalAccessoryBar({
|
||||||
|
inputReady,
|
||||||
|
modifiers,
|
||||||
|
fontSize,
|
||||||
|
onRememberInputFocus,
|
||||||
|
onToggleModifier,
|
||||||
|
onWriteKey,
|
||||||
|
onScrollPages,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
|
onToggleKeyboard,
|
||||||
|
onChangeFontSize,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="terminal-accessories"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Terminal controls"
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
// Preserve whether xterm was focused before the button library moves
|
||||||
|
// focus. The keyboard button consumes this to show or hide mobile IME.
|
||||||
|
preserveInputFocus(event, onRememberInputFocus);
|
||||||
|
}}
|
||||||
|
onMouseDownCapture={(event) => {
|
||||||
|
// Firefox can take the mouse-event path without emitting pointerdown.
|
||||||
|
preserveInputFocus(event, onRememberInputFocus);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="terminal-accessory-group">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key"
|
||||||
|
data-active={modifiers.ctrl || undefined}
|
||||||
|
aria-pressed={modifiers.ctrl}
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={() => onToggleModifier("ctrl")}
|
||||||
|
>
|
||||||
|
Ctrl
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key"
|
||||||
|
data-active={modifiers.meta || undefined}
|
||||||
|
aria-pressed={modifiers.meta}
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={() => onToggleModifier("meta")}
|
||||||
|
>
|
||||||
|
Meta
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key"
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={() => onWriteKey("\x1b")}
|
||||||
|
>
|
||||||
|
Esc
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key"
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={() => onWriteKey("\t")}
|
||||||
|
>
|
||||||
|
Tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-accessory-group">
|
||||||
|
{CONTROL_KEYS.map(([label, sequence, title]) => (
|
||||||
|
<Button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--control"
|
||||||
|
aria-label={title}
|
||||||
|
title={title}
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={() => onWriteKey(sequence)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-accessory-group">
|
||||||
|
{ARROW_KEYS.map(([label, sequence, Icon]) => (
|
||||||
|
<Button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label={label}
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={() => onWriteKey(sequence)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-accessory-group">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--wide"
|
||||||
|
onClick={() => onScrollPages(-1)}
|
||||||
|
>
|
||||||
|
PgUp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--wide"
|
||||||
|
onClick={() => onScrollPages(1)}
|
||||||
|
>
|
||||||
|
PgDn
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-accessory-group">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label="Copy selection or visible screen"
|
||||||
|
title="Copy selection or visible screen"
|
||||||
|
onClick={onCopy}
|
||||||
|
>
|
||||||
|
<ClipboardCopy className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label="Paste"
|
||||||
|
title="Paste"
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={onPaste}
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
aria-label="Toggle terminal keyboard"
|
||||||
|
title="Toggle terminal keyboard"
|
||||||
|
disabled={!inputReady}
|
||||||
|
onClick={onToggleKeyboard}
|
||||||
|
>
|
||||||
|
<Keyboard className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="terminal-accessory-group terminal-font-controls">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
|
||||||
|
aria-label="Decrease terminal text size"
|
||||||
|
onClick={() => onChangeFontSize(-1)}
|
||||||
|
>
|
||||||
|
<ZoomOut className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<output aria-label="Terminal text size">{fontSize}</output>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-key terminal-key--icon"
|
||||||
|
disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
|
||||||
|
aria-label="Increase terminal text size"
|
||||||
|
onClick={() => onChangeFontSize(1)}
|
||||||
|
>
|
||||||
|
<ZoomIn className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { Plus, Terminal, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { Agent, TerminalSession } from "@/api";
|
||||||
|
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { TerminalConnectionState } from "@/pages/terminal/sessionUtils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
sessions: TerminalSession[];
|
||||||
|
activeTerminalId?: string;
|
||||||
|
connection: TerminalConnectionState;
|
||||||
|
sessionTitles: Record<string, string>;
|
||||||
|
nowUnix: number;
|
||||||
|
canRequestStart: boolean;
|
||||||
|
agentAtSessionLimit: boolean;
|
||||||
|
selectedAgentSessionLimit: number;
|
||||||
|
onActivate: (session: TerminalSession) => void;
|
||||||
|
onClose: (session: TerminalSession) => void;
|
||||||
|
onStart: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function terminalCreatedTime(createdAtUnix: number): string {
|
||||||
|
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalTimestamp(timestampUnix: number): string {
|
||||||
|
return new Date(timestampUnix * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalExpiryDisplay(
|
||||||
|
expiresAtUnix: number | null | undefined,
|
||||||
|
nowUnix: number,
|
||||||
|
): { label: string; warning: boolean } | null {
|
||||||
|
if (expiresAtUnix == null) return null;
|
||||||
|
const remainingSeconds = expiresAtUnix - nowUnix;
|
||||||
|
if (remainingSeconds <= 0) return { label: "expired", warning: true };
|
||||||
|
if (remainingSeconds < 60) return { label: "<1m left", warning: true };
|
||||||
|
if (remainingSeconds < 3600) {
|
||||||
|
return {
|
||||||
|
label: `${Math.floor(remainingSeconds / 60)}m left`,
|
||||||
|
warning: remainingSeconds < 10 * 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (remainingSeconds < 24 * 3600) {
|
||||||
|
return {
|
||||||
|
label: `${Math.floor(remainingSeconds / 3600)}h left`,
|
||||||
|
warning: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: `${Math.floor(remainingSeconds / (24 * 3600))}d left`,
|
||||||
|
warning: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalSessionTabs({
|
||||||
|
agents,
|
||||||
|
sessions,
|
||||||
|
activeTerminalId,
|
||||||
|
connection,
|
||||||
|
sessionTitles,
|
||||||
|
nowUnix,
|
||||||
|
canRequestStart,
|
||||||
|
agentAtSessionLimit,
|
||||||
|
selectedAgentSessionLimit,
|
||||||
|
onActivate,
|
||||||
|
onClose,
|
||||||
|
onStart,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="terminal-tabs">
|
||||||
|
<div
|
||||||
|
className="terminal-tab-list"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Terminal sessions"
|
||||||
|
>
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="terminal-session-label">
|
||||||
|
<Terminal className="size-4" aria-hidden />
|
||||||
|
<span>No active session</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((item) => {
|
||||||
|
const agent = agents.find(
|
||||||
|
(candidate) => candidate.agent_id === item.agent_id,
|
||||||
|
);
|
||||||
|
const active = item.terminal_id === activeTerminalId;
|
||||||
|
const locked = item.operator_attached && !active;
|
||||||
|
const agentLabel = agent ? displayAgentLabel(agent) : item.agent_id;
|
||||||
|
const sessionTitle = sessionTitles[item.terminal_id];
|
||||||
|
const tabLabel = sessionTitle
|
||||||
|
? `${agentLabel} · ${sessionTitle}`
|
||||||
|
: agentLabel;
|
||||||
|
const expiry = terminalExpiryDisplay(item.expires_at_unix, nowUnix);
|
||||||
|
const timingTitle =
|
||||||
|
item.expires_at_unix != null
|
||||||
|
? `Expires ${terminalTimestamp(item.expires_at_unix)}`
|
||||||
|
: "No expiry";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.terminal_id}
|
||||||
|
role="presentation"
|
||||||
|
className="terminal-tab"
|
||||||
|
data-active={active || undefined}
|
||||||
|
data-locked={locked || undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
className="terminal-tab-select"
|
||||||
|
disabled={locked || connection === "connecting"}
|
||||||
|
title={`${locked ? "Attached in another browser\n" : ""}${tabLabel}\nCreated ${terminalTimestamp(item.created_at_unix)}\n${timingTitle}`}
|
||||||
|
onClick={() => onActivate(item)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="terminal-tab-dot"
|
||||||
|
data-state={
|
||||||
|
active
|
||||||
|
? connection
|
||||||
|
: locked
|
||||||
|
? "attached"
|
||||||
|
: item.agent_attached
|
||||||
|
? "detached"
|
||||||
|
: "disconnected"
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="terminal-tab-label">{tabLabel}</span>
|
||||||
|
<time
|
||||||
|
className={
|
||||||
|
expiry?.warning ? "text-amber-400" : "text-[#7f8d9a]"
|
||||||
|
}
|
||||||
|
dateTime={new Date(
|
||||||
|
(item.expires_at_unix ?? item.created_at_unix) * 1000,
|
||||||
|
).toISOString()}
|
||||||
|
>
|
||||||
|
{expiry?.label ?? terminalCreatedTime(item.created_at_unix)}
|
||||||
|
</time>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="terminal-tab-close"
|
||||||
|
disabled={locked}
|
||||||
|
aria-label={`Close ${agentLabel} terminal session`}
|
||||||
|
title={
|
||||||
|
locked ? "Attached in another browser" : "Close session"
|
||||||
|
}
|
||||||
|
onClick={() => onClose(item)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="terminal-new-tab"
|
||||||
|
disabled={!canRequestStart}
|
||||||
|
aria-label="New terminal session"
|
||||||
|
onClick={onStart}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{agentAtSessionLimit
|
||||||
|
? `${selectedAgentSessionLimit}-session limit reached`
|
||||||
|
: "New session"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||||
|
|
||||||
|
import type { TerminalSession } from "@/api";
|
||||||
|
|
||||||
|
export type TerminalConnectionState =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "ready"
|
||||||
|
| "disconnected"
|
||||||
|
| "exited";
|
||||||
|
|
||||||
|
export type TerminalModifiers = {
|
||||||
|
ctrl: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NO_TERMINAL_MODIFIERS: TerminalModifiers = {
|
||||||
|
ctrl: false,
|
||||||
|
meta: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mergeTerminalSession(
|
||||||
|
sessions: TerminalSession[],
|
||||||
|
next: TerminalSession,
|
||||||
|
): TerminalSession[] {
|
||||||
|
const existingIndex = sessions.findIndex(
|
||||||
|
(item) => item.terminal_id === next.terminal_id,
|
||||||
|
);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Attaching updates session state, not identity or tab position. Removing
|
||||||
|
// and re-inserting here made same-second sessions swap places on click.
|
||||||
|
return sessions.map((item, index) =>
|
||||||
|
index === existingIndex ? next : item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return orderTerminalSessions([...sessions, next]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTerminalSessions(
|
||||||
|
left: TerminalSession,
|
||||||
|
right: TerminalSession,
|
||||||
|
): number {
|
||||||
|
return (
|
||||||
|
left.created_at_unix - right.created_at_unix ||
|
||||||
|
left.terminal_id.localeCompare(right.terminal_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderTerminalSessions(
|
||||||
|
sessions: TerminalSession[],
|
||||||
|
): TerminalSession[] {
|
||||||
|
return [...sessions].sort(compareTerminalSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileTerminalSessions(
|
||||||
|
current: TerminalSession[],
|
||||||
|
listed: TerminalSession[],
|
||||||
|
): TerminalSession[] {
|
||||||
|
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||||
|
const retained = current.flatMap((item) => {
|
||||||
|
const updated = listedById.get(item.terminal_id);
|
||||||
|
if (!updated) return [];
|
||||||
|
listedById.delete(item.terminal_id);
|
||||||
|
return [updated];
|
||||||
|
});
|
||||||
|
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreCandidates(
|
||||||
|
sessions: TerminalSession[],
|
||||||
|
rememberedId: string | null,
|
||||||
|
selectedAgentId: string,
|
||||||
|
): TerminalSession[] {
|
||||||
|
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||||
|
const available = sessions.filter(
|
||||||
|
(item) => !item.operator_attached && item !== remembered,
|
||||||
|
);
|
||||||
|
available.sort((left, right) => {
|
||||||
|
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||||
|
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||||
|
return (
|
||||||
|
rightPreferred - leftPreferred ||
|
||||||
|
right.created_at_unix - left.created_at_unix
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return remembered ? [remembered, ...available] : available;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTerminalModifiers(
|
||||||
|
data: string,
|
||||||
|
modifiers: TerminalModifiers,
|
||||||
|
): string {
|
||||||
|
let modified = data;
|
||||||
|
if (modifiers.ctrl && modified.length === 1) {
|
||||||
|
const code = modified.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 64 && code <= 95) {
|
||||||
|
modified = String.fromCharCode(code & 0x1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modifiers.meta ? `\x1b${modified}` : modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleTerminalText(terminal: XTerm): string {
|
||||||
|
const buffer = terminal.buffer.active;
|
||||||
|
const firstLine = buffer.viewportY;
|
||||||
|
const lastLine = Math.min(buffer.length, firstLine + terminal.rows);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let index = firstLine; index < lastLine; index += 1) {
|
||||||
|
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||||
|
}
|
||||||
|
while (lines.at(-1) === "") lines.pop();
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
+100
-3
@@ -326,7 +326,7 @@
|
|||||||
|
|
||||||
.terminal-page {
|
.terminal-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100dvh - 2.5rem);
|
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -488,7 +488,6 @@
|
|||||||
|
|
||||||
.terminal-tab time {
|
.terminal-tab time {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #7f8d9a;
|
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +577,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.terminal-surface {
|
.terminal-surface {
|
||||||
|
order: 2;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -585,6 +585,87 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-accessories-toggle[data-active] {
|
||||||
|
color: #6ee7a8;
|
||||||
|
background: rgb(110 231 168 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories {
|
||||||
|
display: flex;
|
||||||
|
order: 3;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.45rem max(0.35rem, env(safe-area-inset-bottom));
|
||||||
|
overflow-x: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
border-top: 1px solid rgb(255 255 255 / 9%);
|
||||||
|
background: #101820;
|
||||||
|
color: #cbd5e1;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
touch-action: pan-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessory-group {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessory-group + .terminal-accessory-group {
|
||||||
|
padding-left: 0.35rem;
|
||||||
|
border-left: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key {
|
||||||
|
width: auto;
|
||||||
|
min-width: 2.4rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding-inline: 0.55rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 9%);
|
||||||
|
border-radius: calc(var(--radius) - 3px);
|
||||||
|
background: rgb(255 255 255 / 4%);
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key:hover {
|
||||||
|
background: rgb(255 255 255 / 9%);
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key[data-active] {
|
||||||
|
border-color: rgb(110 231 168 / 48%);
|
||||||
|
background: rgb(110 231 168 / 14%);
|
||||||
|
color: #9af0bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key--icon {
|
||||||
|
width: 2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key--control {
|
||||||
|
min-width: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key--wide {
|
||||||
|
min-width: 3.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-font-controls output {
|
||||||
|
min-width: 1.5rem;
|
||||||
|
color: #91a0ae;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-surface .xterm {
|
.terminal-surface .xterm {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -609,7 +690,7 @@
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.terminal-page {
|
.terminal-page {
|
||||||
height: calc(100dvh - 2.5rem);
|
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-controls {
|
.terminal-controls {
|
||||||
@@ -626,6 +707,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.terminal-accessories {
|
||||||
|
min-height: 3.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key {
|
||||||
|
min-width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-accessories .terminal-key--icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.presence-dot {
|
.presence-dot {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Wakey JetBrainsMono Nerd Font Mono";
|
||||||
|
src: url("../assets/fonts/jetbrains-mono-nerd/JetBrainsMonoNerdFontMono-Regular.woff2")
|
||||||
|
format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import "./terminalFonts.css";
|
||||||
|
|
||||||
|
const TERMINAL_WEB_FONT = "Wakey JetBrainsMono Nerd Font Mono";
|
||||||
|
const TERMINAL_FALLBACK_FONTS =
|
||||||
|
'"SFMono-Regular", Consolas, "Liberation Mono", monospace';
|
||||||
|
|
||||||
|
let terminalFontsPromise: Promise<string> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the terminal font stack only after browser glyph metrics are ready.
|
||||||
|
* xterm caches those metrics when it opens, so a late webfont load permanently
|
||||||
|
* misaligns cells for that terminal instance.
|
||||||
|
*/
|
||||||
|
export function loadTerminalFontFamily(): Promise<string> {
|
||||||
|
terminalFontsPromise ??= document.fonts
|
||||||
|
.load(`400 14px "${TERMINAL_WEB_FONT}"`, "\uE0B0")
|
||||||
|
.then((faces) => {
|
||||||
|
if (faces.length === 0) {
|
||||||
|
throw new Error("terminal webfont was not registered");
|
||||||
|
}
|
||||||
|
return `"${TERMINAL_WEB_FONT}", ${TERMINAL_FALLBACK_FONTS}`;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn(
|
||||||
|
"Could not load terminal webfont; using system monospace",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return TERMINAL_FALLBACK_FONTS;
|
||||||
|
});
|
||||||
|
return terminalFontsPromise;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"ignoreDeprecations": "5.0",
|
"ignoreDeprecations": "5.0",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/vt100 updated: ea740d5ad1...56e9e0be17.
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-agent"
|
name = "wakey-agent"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
@@ -39,6 +39,6 @@ tracing-subscriber = { version = "0.3", features = [
|
|||||||
] }
|
] }
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.1" }
|
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
||||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-control-plane"
|
name = "wakey-control-plane"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
|
|||||||
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
|
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/ui/",
|
"/ui/",
|
||||||
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
|
get_service(ServeDir::new(ui_dist_dir).fallback(ServeFile::new(index_file))),
|
||||||
)
|
)
|
||||||
.route("/healthz", get(api::healthz))
|
.route("/healthz", get(api::healthz))
|
||||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-core"
|
name = "wakey-core"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wakey-linux"
|
name = "wakey-linux"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = ["gitea"]
|
publish = ["gitea"]
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user