Compare commits

..

10 Commits

Author SHA1 Message Date
izackp ac18f65094 docs: update README, AGENTS.md, Makefile, and architecture docs
CI / check (push) Has been cancelled
2026-06-04 22:10:50 -04:00
izackp a7b5c13d4b chore: ignore .bkit/ local tool dir 2026-06-04 21:58:41 -04:00
izackp 6c273feda7 feat: add PWA offline support and model caching
Add service worker that enables full offline launch and persistent
caching of large Moonshine voice model assets (136MB .data file).

- sw.js: stale-while-revalidate for app shell (HTML, JS, CSS, fonts);
  cache-first for sherpa model files and ghostty WASM; old shell caches
  deleted on SW activate to reclaim storage after updates
- server.go: /sw.js route injects current staticAssetCacheBust version
  into SW content so browser detects new SW on each deploy; new SW
  pre-caches versioned terminal.js during install so update is ready
  before next launch; SW registration added to dashboard HTML
- terminal.ts: register /sw.js on load; detect navigator.onLine at
  startup; listen for online/offline events; pause reconnect loop when
  offline instead of exhausting attempts; resume with reset attempts
  when network returns; show "Offline. Will reconnect..." instead of
  misleading WebSocket error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:59:13 -04:00
izackp 35fa7b5111 Fix settings button 2026-05-12 17:42:24 -04:00
izackp d9da2a5b85 feat: add frontend client logging 2026-05-12 16:17:25 -04:00
izackp 1859700f71 fix: improve mobile keyboard behavior 2026-05-12 14:39:09 -04:00
izackp 6a2c1609cd feat: add llm cleanup voice mode 2026-05-12 13:53:38 -04:00
izackp ea1ab6b2ce feat: add env-based auth config support 2026-05-12 12:03:00 -04:00
izackp 1696391441 fix: restore mobile virtual keyboard flow 2026-05-12 12:00:44 -04:00
izackp 541e0e1fe8 feat: redesign mobile keyboard 2026-05-12 03:34:49 -04:00
23 changed files with 2782 additions and 1230 deletions
+3
View File
@@ -21,3 +21,6 @@ bin/
# Frontend
node_modules/
bun.lockb
webterm/static/js/terminal.js
.bkit/
+6 -2
View File
@@ -2,7 +2,7 @@
## Project Structure & Module Organization
`cmd/webterm/` contains the CLI entrypoint. Core server, session, Docker, replay, screenshot, and static-serving code lives in `webterm/`. Shared internal helpers live in `internal/`. Frontend terminal code is in `webterm/static/js/terminal.ts`, with the bundled output committed as `webterm/static/js/terminal.js`. Static assets such as fonts, icons, and WASM files live under `webterm/static/`. Documentation and reference media live in `docs/`.
`cmd/webterm/` contains the CLI entrypoint. Core server, session, Docker, replay, screenshot, and static-serving code lives in `webterm/`. Shared internal helpers live in `internal/`. Frontend terminal code is in `webterm/static/js/terminal.ts`, which bundles to generated output at `webterm/static/js/terminal.js`. Static assets such as fonts, icons, and WASM files live under `webterm/static/`. Documentation and reference media live in `docs/`.
## Build, Test, and Development Commands
@@ -18,12 +18,16 @@
## Coding Style & Naming Conventions
Use `gofmt` for Go formatting; run `make format` before submitting Go-heavy changes. Follow existing Go naming: exported identifiers use `CamelCase`, internal helpers use `camelCase`, and tests live beside source files. TypeScript in `webterm/static/js/` uses strict mode, 2-space indentation, and direct DOM-oriented code rather than framework abstractions. Keep generated bundles in sync with source changes.
Use `gofmt` for Go formatting; run `make format` before submitting Go-heavy changes. Follow existing Go naming: exported identifiers use `CamelCase`, internal helpers use `camelCase`, and tests live beside source files. TypeScript in `webterm/static/js/` uses strict mode, 2-space indentation, and direct DOM-oriented code rather than framework abstractions. Rebuild generated bundles when frontend source changes.
## Testing Guidelines
Go tests use the standard `testing` package. Name tests `TestXxx` and fuzz tests `FuzzXxx`; keep them next to the code they validate. Prefer focused unit tests for `webterm/` and `internal/` changes. Run `make test` for normal work and `make check` before opening a PR. Frontend changes should at minimum pass `bun run typecheck` via `make build`.
## Debugging Rule
Before making a second patch for the same bug, identify the exact callsite that causes the side effect. Trace the real owner of the behavior end-to-end, including library or dependency code when needed, instead of only patching app-level symptoms.
## Commit & Pull Request Guidelines
Recent history uses short imperative subjects, sometimes with prefixes such as `feat:`, `fix:`, and `deps:`. Keep commits focused, e.g. `fix: restore websocket reconnect on hidden-tab resume`. PRs should explain user-visible behavior, note test coverage, and include screenshots or recordings for terminal/UI changes. Link related issues when applicable.
+4
View File
@@ -54,3 +54,7 @@ Run `go run ./cmd/webterm` to start the server on port 8080.
- The `SessionConnector` interface decouples session I/O from the WebSocket layer
- Docker integration uses raw HTTP against the Docker socket (no Docker SDK dependency)
- Version is read from the `VERSION` file and injected via `-ldflags` at build time
## Debugging Rule
Before making a second patch for the same bug, identify the exact callsite that causes the side effect. Trace the real owner of the behavior end-to-end, including dependency code such as `ghostty-web` when necessary, instead of stacking app-level symptom fixes.
+1 -1
View File
@@ -57,7 +57,7 @@ bundle-watch: node_modules ## Watch mode for frontend development
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
bun run watch
build-go: ## Build Go CLI binary
build-go: build-fast ## Build Go CLI binary
cd $(GO_DIR) && mkdir -p bin && go build -ldflags "$(GO_VERSION_LDFLAGS)" -o ./bin/webterm ./cmd/webterm
clean: ## Remove coverage artifacts
+12
View File
@@ -1,3 +1,15 @@
# Deferred: Ghostty mobile long-press copy
## Summary
Mobile long-press copy for highlighted terminal text should be implemented in Ghostty ownership layer, not in `webterm/static/js/terminal.ts`.
## Note
- Selection, copy, context menu, and touch-selection semantics are owned by `ghostty-web`, mainly `lib/selection-manager.ts`.
- Wrapper-level gesture handling in `webterm/static/js/terminal.ts` is not correct long-term fix for “long press highlighted text to copy”.
- Future work should patch Ghostty selection manager directly so long-press copy uses Ghosttys real selection state and copy path.
# Bug: Render loop dies silently on uncaught exception
## Summary
+4 -3
View File
@@ -35,23 +35,24 @@ go install github.com/rcarmo/webterm/cmd/webterm@latest
```bash
git clone https://github.com/rcarmo/webterm.git
cd webterm
mkdir -p bin
go build -o ./bin/webterm ./cmd/webterm
make build-go
```
The command above produces `bin/webterm`; you can also build it from repo root with `make build-go`.
That produces `bin/webterm` and rebuilds generated frontend assets first.
## Quick start
Run a default shell session:
```bash
make build-fast
go run ./cmd/webterm
```
Run a specific command:
```bash
make build-fast
go run ./cmd/webterm -- htop
```
-3
View File
@@ -6,7 +6,6 @@
"name": "webterm-frontend",
"dependencies": {
"ghostty-web": "github:rcarmo/ghostty-web#fcc47d423a7fce1c02c702b6464d0b1ab89175f1",
"simple-keyboard": "^3.8.141",
},
"devDependencies": {
"typescript": "^5.7.0",
@@ -16,8 +15,6 @@
"packages": {
"ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#fcc47d4", {}, "rcarmo-ghostty-web-fcc47d4", "sha512-tq0cFciI32VTyOXDoLHQQDndeA6jhFuZ/3TWYx3VlYDzRhYkWAtTBi6t29isYPzdiKNIWggjkn3Ve/+Qub/wBg=="],
"simple-keyboard": ["simple-keyboard@3.8.141", "", {}, "sha512-5kS9RUYk89alam3XsLzOeLHtPMO0km3Lt1d92+Z4P/mhBqomXanFkMDnb6A4EUHA+v3/dbH/IcBT/85l1rnTiA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
}
}
+2 -2
View File
@@ -5,7 +5,7 @@
`webterm` is a Go HTTP/WebSocket server that hosts one or more terminal sessions and renders screenshot/telemetry surfaces for a dashboard UI.
```
Browser (terminal.js + ghostty-vt.wasm)
Browser (generated terminal.js + ghostty-vt.wasm)
│ WS / HTTP / SSE
@@ -42,7 +42,7 @@ webterm/server.go (LocalServer)
Assets live in `webterm/static`:
- `js/terminal.ts` source
- `js/terminal.js` bundled client
- `js/terminal.js` generated bundled client
- `js/ghostty-vt.wasm`
- `monospace.css`, icons, `manifest.json`
+3 -3
View File
@@ -5,9 +5,9 @@
},
"private": true,
"scripts": {
"build": "bun run typecheck && bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/",
"build:fast": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --watch --target=browser",
"build": "bun run typecheck && bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\" && cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/",
"build:fast": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --minify --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\"",
"watch": "bun build webterm/static/js/terminal.ts --outfile=webterm/static/js/terminal.js --watch --target=browser --define __WEBTERM_BUILD_VERSION__=\\\"$(cat VERSION 2>/dev/null || echo dev)\\\"",
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm webterm/static/js/"
},
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
NGINX_CONF="${NGINX_CONF:-/etc/nginx/nginx.conf}"
LLM_PROXY_PATH="${LLM_PROXY_PATH:-/llm/}"
LLM_UPSTREAM="${LLM_UPSTREAM:-http://127.0.0.1:11435/}"
if [[ ! -f "$NGINX_CONF" ]]; then
echo "nginx config not found: $NGINX_CONF" >&2
exit 1
fi
if [[ $EUID -ne 0 ]]; then
echo "run as root: sudo $0" >&2
exit 1
fi
backup_path="${NGINX_CONF}.webterm-llm-$(date +%Y%m%d-%H%M%S).bak"
cp "$NGINX_CONF" "$backup_path"
echo "backup created: $backup_path"
python3 - "$NGINX_CONF" "$LLM_PROXY_PATH" "$LLM_UPSTREAM" <<'PY'
from pathlib import Path
import sys
config_path = Path(sys.argv[1])
location_path = sys.argv[2]
upstream = sys.argv[3]
text = config_path.read_text()
if f"location {location_path}" in text:
print(f"proxy location already present: {location_path}")
raise SystemExit(0)
target = """ location / {\n if ($valid_origin = "0") { return 403; }\n proxy_pass http://127.0.0.1:8080;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection "upgrade";\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }\n"""
replacement = f""" location {location_path} {{\n if ($valid_origin = "0") {{ return 403; }}\n proxy_pass {upstream};\n proxy_http_version 1.1;\n proxy_connect_timeout 30s;\n proxy_send_timeout 300s;\n proxy_read_timeout 300s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n }}\n\n{target}"""
if target not in text:
print("target webterm location block not found in nginx.conf", file=sys.stderr)
raise SystemExit(1)
config_path.write_text(text.replace(target, replacement, 1))
print(f"inserted proxy location {location_path} -> {upstream}")
PY
nginx -t
echo "nginx config valid"
echo "reload when ready: sudo systemctl reload nginx"
+94
View File
@@ -3,6 +3,97 @@ set -e
cd "$(dirname "$0")"
SERVICE_FILE="$HOME/.config/systemd/user/webterm.service"
DEFAULT_USERNAME="izackp"
DEFAULT_TTL_SECONDS="86400"
UPDATE_AUTH_PASSWORD=0
if [ ! -f "$SERVICE_FILE" ]; then
echo "Missing service file: $SERVICE_FILE" >&2
exit 1
fi
while getopts ":p" opt; do
case "$opt" in
p)
UPDATE_AUTH_PASSWORD=1
;;
*)
echo "Usage: $0 [-p]" >&2
exit 1
;;
esac
done
shift $((OPTIND - 1))
if [ "$UPDATE_AUTH_PASSWORD" -eq 1 ]; then
echo "Enter password for webterm login."
read -rsp "Password for $DEFAULT_USERNAME: " WEBTERM_PASSWORD
echo
if [ -z "$WEBTERM_PASSWORD" ]; then
echo "Password cannot be empty." >&2
exit 1
fi
fi
CURRENT_SECRET="$(sed -n 's/^Environment=WEBTERM_AUTH_COOKIE_SECRET=//p' "$SERVICE_FILE" | tail -n 1)"
if [ -z "$CURRENT_SECRET" ]; then
CURRENT_SECRET="$(openssl rand -hex 32)"
fi
if [ "$UPDATE_AUTH_PASSWORD" -eq 1 ]; then
TMP_SERVICE="$(mktemp)"
awk '
BEGIN {
skip["Environment=WEBTERM_AUTH_USERNAME"]=1
skip["Environment=WEBTERM_AUTH_PASSWORD"]=1
skip["Environment=WEBTERM_AUTH_COOKIE_SECRET"]=1
skip["Environment=WEBTERM_AUTH_SESSION_TTL_SECONDS"]=1
}
{
for (prefix in skip) {
if (index($0, prefix) == 1) next
}
print
}
' "$SERVICE_FILE" > "$TMP_SERVICE"
python3 - "$TMP_SERVICE" "$WEBTERM_PASSWORD" "$CURRENT_SECRET" "$DEFAULT_TTL_SECONDS" <<'PYEOF'
import sys
from pathlib import Path
import shlex
path = Path(sys.argv[1])
username = "izackp"
password = sys.argv[2]
secret = sys.argv[3]
ttl = sys.argv[4]
lines = path.read_text().splitlines()
insert = [
f"Environment=WEBTERM_AUTH_USERNAME={shlex.quote(username)}",
f"Environment=WEBTERM_AUTH_PASSWORD={shlex.quote(password)}",
f"Environment=WEBTERM_AUTH_COOKIE_SECRET={shlex.quote(secret)}",
f"Environment=WEBTERM_AUTH_SESSION_TTL_SECONDS={shlex.quote(ttl)}",
]
out = []
inserted = False
for line in lines:
out.append(line)
if line.strip() == "[Service]":
out.extend(insert)
inserted = True
if not inserted:
raise SystemExit("Could not find [Service] section in webterm.service")
path.write_text("\n".join(out) + "\n")
PYEOF
mv "$TMP_SERVICE" "$SERVICE_FILE"
fi
echo "Building frontend..."
make build
@@ -16,6 +107,9 @@ cp bin/webterm "$tmp_target"
chmod +x "$tmp_target"
mv "$tmp_target" ~/go/bin/webterm
echo "Reloading user systemd config..."
systemctl --user daemon-reload
echo "Restarting service..."
systemctl --user restart webterm.service
+124
View File
@@ -0,0 +1,124 @@
package webterm
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const (
authCookieName = "webterm_session"
defaultAuthUsername = "izackp"
defaultAuthSessionTTL = 24 * time.Hour
)
type authConfig struct {
username string
password string
secret []byte
sessionTTL time.Duration
}
func newAuthConfig() *authConfig {
username := strings.TrimSpace(os.Getenv(AuthUsernameEnv))
if username == "" {
username = defaultAuthUsername
}
password := strings.TrimSpace(os.Getenv(AuthPasswordEnv))
if password == "" {
return nil
}
sessionTTL := defaultAuthSessionTTL
if raw := strings.TrimSpace(os.Getenv(AuthSessionTTLSecondsEnv)); raw != "" {
if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 {
sessionTTL = time.Duration(seconds) * time.Second
}
}
secretRaw := strings.TrimSpace(os.Getenv(AuthCookieSecretEnv))
if secretRaw == "" {
sum := sha256.Sum256([]byte("webterm:" + password))
secretRaw = hex.EncodeToString(sum[:])
}
return &authConfig{
username: username,
password: password,
secret: []byte(secretRaw),
sessionTTL: sessionTTL,
}
}
func (a *authConfig) cookieValue(now time.Time) string {
expiresAt := now.Add(a.sessionTTL).Unix()
payload := fmt.Sprintf("v1:%d", expiresAt)
mac := hmac.New(sha256.New, a.secret)
_, _ = mac.Write([]byte(payload))
signature := hex.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload + ":" + signature))
}
func (a *authConfig) validCookieValue(value string, now time.Time) bool {
decoded, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return false
}
parts := strings.Split(string(decoded), ":")
if len(parts) != 3 || parts[0] != "v1" {
return false
}
expiresAt, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || now.Unix() > expiresAt {
return false
}
payload := parts[0] + ":" + parts[1]
mac := hmac.New(sha256.New, a.secret)
_, _ = mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expected)) == 1
}
func (a *authConfig) passwordMatches(password string) bool {
return subtle.ConstantTimeCompare([]byte(password), []byte(a.password)) == 1
}
func (a *authConfig) usernameMatches(username string) bool {
return subtle.ConstantTimeCompare([]byte(username), []byte(a.username)) == 1
}
func (a *authConfig) sessionCookie(value string, r *http.Request) *http.Cookie {
return &http.Cookie{
Name: authCookieName,
Value: value,
Path: "/",
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
MaxAge: int(a.sessionTTL / time.Second),
}
}
func (a *authConfig) clearCookie(r *http.Request) *http.Cookie {
return &http.Cookie{
Name: authCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
}
}
+8
View File
@@ -13,9 +13,17 @@ const (
DefaultFontSize = 16
DefaultTerminalWidth = 132
DefaultTerminalHeight = 45
DefaultVoiceLLMBaseURL = "/llm"
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE"
VoiceLLMBaseURLEnv = "WEBTERM_VOICE_LLM_BASE_URL"
VoiceLLMModelEnv = "WEBTERM_VOICE_LLM_MODEL"
AuthUsernameEnv = "WEBTERM_AUTH_USERNAME"
AuthPasswordEnv = "WEBTERM_AUTH_PASSWORD"
AuthCookieSecretEnv = "WEBTERM_AUTH_COOKIE_SECRET"
AuthSessionTTLSecondsEnv = "WEBTERM_AUTH_SESSION_TTL_SECONDS"
FrontendLogDirEnv = "WEBTERM_FRONTEND_LOG_DIR"
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
DockerHostEnv = "DOCKER_HOST"
+210 -3
View File
@@ -173,6 +173,7 @@ type LocalServer struct {
fontSize int
screenshotMode string
staticAssetCacheBust string
frontendLogDir string
sessionManager *SessionManager
landingApps []App
@@ -257,6 +258,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
fontFamily: options.FontFamily,
fontSize: fontSize,
screenshotMode: screenshotMode,
frontendLogDir: defaultFrontendLogDir(),
sessionManager: NewSessionManager(apps),
landingApps: append([]App{}, options.LandingApps...),
@@ -323,6 +325,95 @@ func findStaticPath() string {
return ""
}
func defaultFrontendLogDir() string {
if p := strings.TrimSpace(os.Getenv(FrontendLogDirEnv)); p != "" {
return p
}
if stateHome := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); stateHome != "" {
return filepath.Join(stateHome, "webterm", "frontend-logs")
}
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
return filepath.Join(home, ".local", "state", "webterm", "frontend-logs")
}
return filepath.Join(os.TempDir(), "webterm", "frontend-logs")
}
func sanitizeFrontendLogSessionID(value string) (string, bool) {
value = strings.TrimSpace(value)
if value == "" {
return "", false
}
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
return "", false
}
if b.Len() >= 128 {
break
}
}
sanitized := b.String()
if sanitized == "" || sanitized != value {
return "", false
}
return sanitized, true
}
type frontendLogRequest struct {
SessionID string `json:"session_id"`
Text string `json:"text"`
FrontendVersion string `json:"frontend_version"`
Level string `json:"level"`
Context string `json:"context"`
}
func (s *LocalServer) appendFrontendLog(sessionID, text, frontendVersion, level, context string, now time.Time) (string, error) {
if err := os.MkdirAll(s.frontendLogDir, 0o755); err != nil {
return "", err
}
filename := fmt.Sprintf("%s.log", now.Format("2006-01-02"))
path := filepath.Join(s.frontendLogDir, filename)
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return "", err
}
defer file.Close()
if frontendVersion == "" {
frontendVersion = "unknown"
}
record := map[string]string{
"timestamp": now.UTC().Format(time.RFC3339Nano),
"session_id": sessionID,
"text": text,
"frontend_version": frontendVersion,
"server_version": Version,
}
if strings.TrimSpace(level) != "" {
record["level"] = strings.TrimSpace(level)
}
if strings.TrimSpace(context) != "" {
record["context"] = strings.TrimSpace(context)
}
line, err := json.Marshal(record)
if err != nil {
return "", err
}
if _, err := file.Write(append(line, '\n')); err != nil {
return "", err
}
return path, nil
}
func (s *LocalServer) markRouteActivity(routeKey string) {
now := time.Now()
s.mu.Lock()
@@ -527,6 +618,62 @@ func (s *LocalServer) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *LocalServer) handleFrontendLog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload frontendLogRequest
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
switch contentType {
case "", "application/x-www-form-urlencoded", "multipart/form-data":
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
payload.SessionID = r.Form.Get("session_id")
payload.Text = r.Form.Get("text")
payload.FrontendVersion = r.Form.Get("frontend_version")
payload.Level = r.Form.Get("level")
payload.Context = r.Form.Get("context")
default:
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&payload); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
}
sessionID, ok := sanitizeFrontendLogSessionID(payload.SessionID)
if !ok {
http.Error(w, "missing session_id", http.StatusBadRequest)
return
}
text := strings.TrimSpace(payload.Text)
if text == "" {
http.Error(w, "missing text", http.StatusBadRequest)
return
}
path, err := s.appendFrontendLog(
sessionID,
text,
strings.TrimSpace(payload.FrontendVersion),
strings.TrimSpace(payload.Level),
strings.TrimSpace(payload.Context),
time.Now(),
)
if err != nil {
log.Printf("frontend log append failed session_id=%s err=%v", sessionID, err)
http.Error(w, "log write failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"path": path,
})
}
func (s *LocalServer) renderLoginPage(w http.ResponseWriter, r *http.Request) {
errorBanner := ""
if r.URL.Query().Get("auth_error") == "1" {
@@ -1319,6 +1466,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
let searchQuery = '';
let activeResultIndex = -1;
let filteredResults = [];
const clientLogSessionID = 'dashboard-' + Math.random().toString(36).slice(2, 14);
const clientBuildVersion = %q;
const floatingResultsEl = document.getElementById('floating-results');
const keyIndicatorEl = document.getElementById('key-indicator');
const thumbnailCache = {};
@@ -1332,6 +1481,31 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle');
function clientLog(level, text, context) {
const message = (text || '').toString().trim();
if (!message) return;
const payload = {
session_id: clientLogSessionID,
text: message,
frontend_version: clientBuildVersion,
level: level || 'info',
};
if (context) {
try {
payload.context = JSON.stringify(context);
} catch (_) {
payload.context = String(context);
}
}
fetch('/api/client-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
credentials: 'same-origin',
}).catch(() => {});
}
function bellStorageKey(slug) {
return bellStoragePrefix + slug;
}
@@ -1797,7 +1971,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
}
subtitle.textContent = '';
if (dockerWatchMode) {
console.log(tiles.length + ' container(s) found');
clientLog('info', tiles.length + ' container(s) found', { tile_count: tiles.length });
}
for (const tile of tiles) {
const card = makeTile(tile);
@@ -1845,8 +2019,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
setInterval(refreshSparklines, 30000);
}
</script>
<script>if("serviceWorker"in navigator){navigator.serviceWorker.register("/sw.js").catch(function(){})}</script>
</body>
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt)
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt, Version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, html)
return
@@ -1888,7 +2063,19 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
fontFamily = "var(--webterm-mono)"
}
escapedFont := strings.ReplaceAll(fontFamily, `"`, "&quot;")
dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), htmlAttrEscape(routeKey), htmlAttrEscape(app.Name), s.fontSize, htmlAttrEscape(theme), escapedFont)
voiceLLMBaseURL := strings.TrimSpace(os.Getenv(VoiceLLMBaseURLEnv))
voiceLLMModel := strings.TrimSpace(os.Getenv(VoiceLLMModelEnv))
dataAttrs := fmt.Sprintf(
`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s" data-voice-llm-base-url="%s" data-voice-llm-model="%s"`,
htmlAttrEscape(wsURL),
htmlAttrEscape(routeKey),
htmlAttrEscape(app.Name),
s.fontSize,
htmlAttrEscape(theme),
escapedFont,
htmlAttrEscape(voiceLLMBaseURL),
htmlAttrEscape(voiceLLMModel),
)
cacheBust := "?v=" + s.staticAssetCacheBust
page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css%s"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono);display:flex;flex-direction:column;height:100vh;height:100dvh}.webterm-terminal{width:100%%;flex:1;min-height:0;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js%s"></script></body></html>`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -1896,6 +2083,24 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, page)
}
func (s *LocalServer) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
var data []byte
var err error
if s.staticPath != "" {
data, err = os.ReadFile(filepath.Join(s.staticPath, "sw.js"))
} else {
data, err = embeddedStaticAssets.ReadFile("static/sw.js")
}
if err != nil {
http.NotFound(w, r)
return
}
content := strings.ReplaceAll(string(data), "{{SHELL_VERSION}}", s.staticAssetCacheBust)
_, _ = io.WriteString(w, content)
}
func htmlEscape(value string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(value)
}
@@ -1988,7 +2193,9 @@ func (s *LocalServer) Handler() http.Handler {
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
mux.HandleFunc("/events", s.handleEvents)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/api/client-log", s.handleFrontendLog)
mux.HandleFunc("/tiles", s.handleTiles)
mux.HandleFunc("/sw.js", s.handleServiceWorker)
mux.HandleFunc("/", s.handleRoot)
if strings.TrimSpace(s.staticPath) != "" {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath))))
+86
View File
@@ -7,6 +7,8 @@ import (
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -46,6 +48,7 @@ func newServerForTests(t *testing.T, withLanding bool) (*LocalServer, *httptest.
options.LandingApps = []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}}
}
server := NewLocalServer(config, options)
server.frontendLogDir = t.TempDir()
sessions := &syncSessionMap{m: map[string]*fakeSession{}}
server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session {
s := newFakeSession()
@@ -128,6 +131,69 @@ func TestHealthAndTilesEndpoints(t *testing.T) {
}
}
func TestFrontendLogEndpointAppendsJSONLine(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
body := strings.NewReader(`{"session_id":"frontend-abc123","text":"button clicked","frontend_version":"1.3.39"}`)
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/client-log", body)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post frontend log: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d body=%q", resp.StatusCode, string(data))
}
var payload map[string]string
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
logPath := payload["path"]
if filepath.Base(logPath) != time.Now().Format("2006-01-02")+".log" {
t.Fatalf("unexpected log path: %q", logPath)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log file: %v", err)
}
var record map[string]string
if err := json.Unmarshal([]byte(strings.TrimSpace(string(data))), &record); err != nil {
t.Fatalf("decode log line: %v", err)
}
if record["session_id"] != "frontend-abc123" || record["text"] != "button clicked" {
t.Fatalf("unexpected log record: %+v", record)
}
if record["frontend_version"] != "1.3.39" || record["server_version"] == "" {
t.Fatalf("missing version fields: %+v", record)
}
}
func TestFrontendLogEndpointRejectsInvalidSessionID(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
body := strings.NewReader(`{"session_id":"../../bad","text":"oops"}`)
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/api/client-log", body)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post frontend log: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
data, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 400, got %d body=%q", resp.StatusCode, string(data))
}
}
func TestWebSocketPingResizeAndStdin(t *testing.T) {
server, httpServer, sessions := newServerForTests(t, false)
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell"
@@ -588,6 +654,26 @@ func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
}
}
func TestRootTerminalPageIncludesVoiceLLMConfig(t *testing.T) {
t.Setenv(VoiceLLMBaseURLEnv, "http://cachy.lan:11434")
t.Setenv(VoiceLLMModelEnv, "llama-cleanup")
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/?route_key=shell")
if err != nil {
t.Fatalf("root request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
text := string(body)
if !strings.Contains(text, `data-voice-llm-base-url="http://cachy.lan:11434"`) {
t.Fatalf("expected voice LLM base URL in page attrs, got %q", text)
}
if !strings.Contains(text, `data-voice-llm-model="llama-cleanup"`) {
t.Fatalf("expected voice LLM model in page attrs, got %q", text)
}
}
func TestMarkRouteActivityBroadcastsWithoutBlockingGlobalLock(t *testing.T) {
server := NewLocalServer(Config{}, ServerOptions{})
ready := make(chan string, 1)
+146
View File
@@ -0,0 +1,146 @@
declare const __WEBTERM_BUILD_VERSION__: string;
export type FrontendLogLevel = "debug" | "info" | "warn" | "error";
export interface WebtermScopedLogger {
debug: (text: string, extra?: Record<string, unknown>) => void;
info: (text: string, extra?: Record<string, unknown>) => void;
warn: (text: string, extra?: Record<string, unknown>) => void;
error: (text: string, extra?: Record<string, unknown>) => void;
}
declare global {
interface Window {
webtermClientLog?: (text: string, extra?: Record<string, unknown>) => Promise<void>;
webtermClientLogSessionID?: string;
webtermClientBuildVersion?: string;
webtermLogs?: {
bug: WebtermScopedLogger;
ui: WebtermScopedLogger;
voice: WebtermScopedLogger;
ws: WebtermScopedLogger;
};
}
}
export const FRONTEND_BUILD_VERSION = __WEBTERM_BUILD_VERSION__;
function createFrontendLogSessionID(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let out = "";
for (let i = 0; i < 24; i += 1) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out;
}
export const frontendLogSessionID = createFrontendLogSessionID();
function normalizeFrontendLogValue(value: unknown): unknown {
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
};
}
if (Array.isArray(value)) {
return value.map((item) => normalizeFrontendLogValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeFrontendLogValue(item)])
);
}
return value;
}
function serializeFrontendLogContext(extra: Record<string, unknown> = {}): string {
const entries = Object.entries(extra);
if (entries.length === 0) {
return "";
}
try {
return JSON.stringify(Object.fromEntries(entries.map(([key, value]) => [key, normalizeFrontendLogValue(value)])));
} catch {
return String(extra);
}
}
export async function postFrontendLog(
level: FrontendLogLevel,
text: string,
extra: Record<string, unknown> = {}
): Promise<void> {
const message = typeof text === "string" ? text.trim() : "";
if (!message) return;
try {
await fetch("/api/client-log", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
session_id: frontendLogSessionID,
text: message,
frontend_version: FRONTEND_BUILD_VERSION,
level,
context: serializeFrontendLogContext(extra),
}),
keepalive: true,
});
} catch (error) {
console.warn("[webterm] Failed to post client log:", error);
}
}
function logFrontend(level: FrontendLogLevel, text: string, extra: Record<string, unknown> = {}): void {
void postFrontendLog(level, text, extra);
}
function logFrontendDebug(text: string, extra: Record<string, unknown> = {}): void {
logFrontend("debug", text, extra);
}
function logFrontendInfo(text: string, extra: Record<string, unknown> = {}): void {
logFrontend("info", text, extra);
}
function logFrontendWarn(text: string, extra: Record<string, unknown> = {}): void {
logFrontend("warn", text, extra);
}
function logFrontendError(text: string, extra: Record<string, unknown> = {}): void {
logFrontend("error", text, extra);
}
export function createScopedFrontendLogger(scope: string): WebtermScopedLogger {
const withScope = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
scope,
...extra,
});
return {
debug: (text, extra = {}) => logFrontendDebug(text, withScope(extra)),
info: (text, extra = {}) => logFrontendInfo(text, withScope(extra)),
warn: (text, extra = {}) => logFrontendWarn(text, withScope(extra)),
error: (text, extra = {}) => logFrontendError(text, withScope(extra)),
};
}
export const bugLog = createScopedFrontendLogger("bug");
export const uiLog = createScopedFrontendLogger("ui");
export const voiceLog = createScopedFrontendLogger("voice");
export const wsLog = createScopedFrontendLogger("ws");
window.webtermClientLog = (text: string, extra: Record<string, unknown> = {}) =>
postFrontendLog("info", text, extra);
window.webtermClientLogSessionID = frontendLogSessionID;
window.webtermClientBuildVersion = FRONTEND_BUILD_VERSION;
window.webtermLogs = {
bug: bugLog,
ui: uiLog,
voice: voiceLog,
ws: wsLog,
};
+65
View File
@@ -0,0 +1,65 @@
const sharedScriptLoads = new Map<string, Promise<void>>();
/** Shared TextDecoder (stateless for UTF-8, safe to share) */
export const sharedTextDecoder = new TextDecoder();
/** Get WASM path based on script location */
export function getWasmPath(): string {
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
if (scripts.length > 0) {
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
const basePath = scriptSrc.substring(0, scriptSrc.lastIndexOf("/") + 1);
return basePath + "ghostty-vt.wasm";
}
return "/static/js/ghostty-vt.wasm";
}
export function getStaticJSBasePath(): string {
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
if (scripts.length > 0) {
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
return scriptSrc.substring(0, scriptSrc.lastIndexOf("/") + 1);
}
return "/static/js/";
}
export function loadScriptOnce(src: string): Promise<void> {
const existing = sharedScriptLoads.get(src);
if (existing) {
return existing;
}
const promise = new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => {
sharedScriptLoads.delete(src);
reject(new Error(`Failed to load script: ${src}`));
};
document.head.appendChild(script);
});
sharedScriptLoads.set(src, promise);
return promise;
}
export function formatSherpaStatus(status: string): string {
if (!status) {
return "";
}
if (status === "Running...") {
return "Model ready";
}
const downloadMatch = status.match(/Downloading data... \((\d+)\/(\d+)\)/);
if (!downloadMatch) {
return status;
}
const downloaded = BigInt(downloadMatch[1]);
const total = BigInt(downloadMatch[2]);
const percent =
total === 0n ? 0 : Number((downloaded * 10_000n) / total) / 100;
return `Downloading ${percent.toFixed(2)}%`;
}
+55
View File
@@ -0,0 +1,55 @@
import type { ITheme } from "ghostty-web";
import { uiLog } from "./frontend-log";
import { DEFAULT_FONT_FAMILY, THEMES } from "./terminal-themes";
export interface TerminalConfig {
fontFamily?: string;
fontSize?: number;
scrollback?: number;
theme?: ITheme;
}
/** Parse configuration from element data attributes */
export function parseConfig(element: HTMLElement): TerminalConfig {
const config: TerminalConfig = {};
if (element.dataset.fontFamily) {
let fontFamily = element.dataset.fontFamily;
// Resolve CSS variables - Canvas 2D context doesn't understand var(--name) syntax
if (fontFamily.startsWith("var(")) {
const varMatch = fontFamily.match(/var\(([^)]+)\)/);
if (varMatch) {
const varName = varMatch[1].trim();
const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
if (resolved) {
fontFamily = resolved;
} else {
uiLog.warn("CSS variable not found; using default font", { varName });
fontFamily = DEFAULT_FONT_FAMILY;
}
}
}
config.fontFamily = fontFamily;
}
if (element.dataset.fontSize) {
config.fontSize = parseInt(element.dataset.fontSize, 10);
}
if (element.dataset.scrollback) {
config.scrollback = parseInt(element.dataset.scrollback, 10);
}
if (element.dataset.theme) {
const themeName = element.dataset.theme.toLowerCase();
if (themeName in THEMES) {
config.theme = THEMES[themeName];
} else {
try {
config.theme = JSON.parse(element.dataset.theme) as ITheme;
} catch (error) {
uiLog.warn("Unknown theme configuration", { theme: element.dataset.theme, error });
}
}
}
return config;
}
+278
View File
@@ -0,0 +1,278 @@
export function isMobileDevice(): boolean {
const touchPoints = navigator.maxTouchPoints || 0;
const coarsePointer =
window.matchMedia?.("(pointer: coarse)").matches ||
window.matchMedia?.("(any-pointer: coarse)").matches ||
false;
const isiPadDesktopMode =
/Macintosh/i.test(navigator.userAgent) && touchPoints > 1;
const hasTouchEvents = "ontouchstart" in window;
return (
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
) ||
isiPadDesktopMode ||
touchPoints > 0 ||
hasTouchEvents ||
coarsePointer
);
}
const SHIFT_KEY_MAP: Record<string, string> = {
"`": "~",
"1": "!",
"2": "@",
"3": "#",
"4": "$",
"5": "%",
"6": "^",
"7": "&",
"8": "*",
"9": "(",
"0": ")",
"-": "_",
"=": "+",
"[": "{",
"]": "}",
"\\": "|",
";": ":",
"'": "\"",
",": "<",
".": ">",
"/": "?",
};
const CTRL_KEY_MAP: Record<string, string> = {
"2": "@",
"3": "[",
"4": "\\",
"5": "]",
"6": "^",
"7": "_",
"8": "?",
};
export type VirtualKeyboardActionId =
| "mode-alpha"
| "mode-symbol"
| "toggle-voice";
export type VirtualKeyboardKey = {
kind: "char" | "modifier" | "action" | "arrow" | "space";
label: string;
modifier?: "ctrl" | "alt" | "shift" | "fn";
shiftLabel?: string;
shiftValue?: string;
value?: string;
seq?: string;
width?: number;
actionId?: VirtualKeyboardActionId;
};
export const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
[
{ kind: "char", label: "q", shiftLabel: "Q" },
{ kind: "char", label: "w", shiftLabel: "W" },
{ kind: "char", label: "e", shiftLabel: "E" },
{ kind: "char", label: "r", shiftLabel: "R" },
{ kind: "char", label: "t", shiftLabel: "T" },
{ kind: "char", label: "y", shiftLabel: "Y" },
{ kind: "char", label: "u", shiftLabel: "U" },
{ kind: "char", label: "i", shiftLabel: "I" },
{ kind: "char", label: "o", shiftLabel: "O" },
{ kind: "char", label: "p", shiftLabel: "P" },
],
[
{ kind: "char", label: "a", shiftLabel: "A" },
{ kind: "char", label: "s", shiftLabel: "S" },
{ kind: "char", label: "d", shiftLabel: "D" },
{ kind: "char", label: "f", shiftLabel: "F" },
{ kind: "char", label: "g", shiftLabel: "G" },
{ kind: "char", label: "h", shiftLabel: "H" },
{ kind: "char", label: "j", shiftLabel: "J" },
{ kind: "char", label: "k", shiftLabel: "K" },
{ kind: "char", label: "l", shiftLabel: "L" },
],
[
{ kind: "modifier", label: "⇧", modifier: "shift" },
{ kind: "char", label: "z", shiftLabel: "Z" },
{ kind: "char", label: "x", shiftLabel: "X" },
{ kind: "char", label: "c", shiftLabel: "C" },
{ kind: "char", label: "v", shiftLabel: "V" },
{ kind: "char", label: "b", shiftLabel: "B" },
{ kind: "char", label: "n", shiftLabel: "N" },
{ kind: "char", label: "m", shiftLabel: "M" },
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
],
[
{ kind: "action", label: "123", actionId: "mode-symbol", width: 1.05 },
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.05 },
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.05 },
{ kind: "space", label: "␣", value: " ", width: 3.1 },
{ kind: "action", label: "Mic", actionId: "toggle-voice", width: 1.15 },
{ kind: "action", label: "⏎", seq: "\r", width: 1.15 },
],
];
export const VIRTUAL_KEYBOARD_SYMBOL_LAYOUT: VirtualKeyboardKey[][] = [
[
{ kind: "char", label: "1", value: "1" },
{ kind: "char", label: "2", value: "2" },
{ kind: "char", label: "3", value: "3" },
{ kind: "char", label: "4", value: "4" },
{ kind: "char", label: "5", value: "5" },
{ kind: "char", label: "6", value: "6" },
{ kind: "char", label: "7", value: "7" },
{ kind: "char", label: "8", value: "8" },
{ kind: "char", label: "9", value: "9" },
{ kind: "char", label: "0", value: "0" },
],
[
{ kind: "char", label: "-", value: "-" },
{ kind: "char", label: "/", value: "/" },
{ kind: "char", label: ":", value: ":" },
{ kind: "char", label: ";", value: ";" },
{ kind: "char", label: "(", value: "(" },
{ kind: "char", label: ")", value: ")" },
{ kind: "char", label: "$", value: "$" },
{ kind: "char", label: "&", value: "&" },
{ kind: "char", label: "@", value: "@" },
{ kind: "char", label: "\"", value: "\"" },
],
[
{ kind: "action", label: "Esc", seq: "\x1b", width: 1.35 },
{ kind: "char", label: ".", value: "." },
{ kind: "char", label: ",", value: "," },
{ kind: "char", label: "?", value: "?" },
{ kind: "char", label: "!", value: "!" },
{ kind: "char", label: "'", value: "'" },
{ kind: "char", label: "[", value: "[" },
{ kind: "char", label: "]", value: "]" },
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f", width: 1.35 },
],
[
{ kind: "action", label: "ABC", actionId: "mode-alpha", width: 1.1 },
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.1 },
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.1 },
{ kind: "space", label: "␣", value: " ", width: 4 },
{ kind: "action", label: "⏎", seq: "\r", width: 1.35 },
],
];
export type VirtualKeyboardKeyBounds = {
x: number;
y: number;
w: number;
h: number;
rowIndex: number;
colIndex: number;
key: VirtualKeyboardKey;
label: string;
value: string;
};
export type ActiveVirtualKeyboardPress = {
pointerId: number;
keyIndex: number;
key: VirtualKeyboardKeyBounds | null;
repeatTimeout?: number;
};
const FN_NORMAL_KEYS = [
"\x1bOP",
"\x1bOQ",
"\x1bOR",
"\x1bOS",
"\x1b[15~",
"\x1b[17~",
"\x1b[18~",
"\x1b[19~",
"\x1b[20~",
"\x1b[21~",
];
const FN_SHIFT_KEYS = [
"\x1b[23~",
"\x1b[24~",
"\x1b[25~",
"\x1b[26~",
"\x1b[28~",
"\x1b[29~",
"\x1b[31~",
"\x1b[32~",
"\x1b[33~",
"\x1b[34~",
];
function applyShiftModifier(key: string): string {
if (key.length !== 1) {
return key;
}
if (key >= "a" && key <= "z") {
return key.toUpperCase();
}
return SHIFT_KEY_MAP[key] ?? key;
}
export function applyCtrlModifier(key: string): string {
if (key.length !== 1) {
return key;
}
const mapped = CTRL_KEY_MAP[key] ?? key;
if (mapped === "?") {
return "\x7f";
}
const code = mapped.toUpperCase().charCodeAt(0);
if (code >= 64 && code <= 95) {
return String.fromCharCode(code - 64);
}
return key;
}
export function applyFnModifier(key: string, useShift: boolean): string | null {
if (key.length !== 1) {
return null;
}
const index = "1234567890".indexOf(key);
if (index < 0) {
return null;
}
return useShift ? FN_SHIFT_KEYS[index] : FN_NORMAL_KEYS[index];
}
export function applyAltModifier(text: string): string {
if (!text || text.startsWith("\x1b")) {
return text;
}
return `\x1b${text}`;
}
export function applyModifiers(
text: string,
useShift: boolean,
useCtrl: boolean,
useAlt: boolean,
useFn: boolean
): string {
if (text.length !== 1) {
return text;
}
if (useFn) {
const fnApplied = applyFnModifier(text, useShift);
if (fnApplied) {
return useAlt ? applyAltModifier(fnApplied) : fnApplied;
}
}
if (useCtrl) {
const ctrlApplied = applyCtrlModifier(text);
if (ctrlApplied !== text) {
return useAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
}
}
if (useShift) {
const shifted = applyShiftModifier(text);
return useAlt ? applyAltModifier(shifted) : shifted;
}
return useAlt ? applyAltModifier(text) : text;
}
+398
View File
@@ -0,0 +1,398 @@
import type { ITheme } from "ghostty-web";
/** Default font stack - prefers system monospace, falls back through programming fonts */
export const DEFAULT_FONT_FAMILY =
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' +
'"DejaVu Sans Mono", "Courier New", monospace';
/** Predefined terminal themes */
export const THEMES: Record<string, ITheme> = {
// Tango - default theme (GNOME/xterm.js colors)
tango: {
background: "#000000",
foreground: "#d3d7cf",
cursor: "#d3d7cf",
cursorAccent: "#000000",
selectionBackground: "#d3d7cf",
selectionForeground: "#000000",
black: "#2e3436",
red: "#cc0000",
green: "#4e9a06",
yellow: "#c4a000",
blue: "#3465a4",
magenta: "#75507b",
cyan: "#06989a",
white: "#d3d7cf",
brightBlack: "#555753",
brightRed: "#ef2929",
brightGreen: "#8ae234",
brightYellow: "#fce94f",
brightBlue: "#729fcf",
brightMagenta: "#ad7fa8",
brightCyan: "#34e2e2",
brightWhite: "#eeeeec",
},
// Classic xterm (VGA colors, pure black background)
xterm: {
background: "#000000",
foreground: "#e5e5e5",
cursor: "#e5e5e5",
cursorAccent: "#000000",
selectionBackground: "#e5e5e5",
selectionForeground: "#000000",
black: "#000000",
red: "#cd0000",
green: "#00cd00",
yellow: "#cdcd00",
blue: "#0000cd",
magenta: "#cd00cd",
cyan: "#00cdcd",
white: "#e5e5e5",
brightBlack: "#4d4d4d",
brightRed: "#ff0000",
brightGreen: "#00ff00",
brightYellow: "#ffff00",
brightBlue: "#0000ff",
brightMagenta: "#ff00ff",
brightCyan: "#00ffff",
brightWhite: "#ffffff",
},
// Monokai Classic
monokai: {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#272822",
selectionBackground: "#75715e",
selectionForeground: "#f8f8f2",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
},
"monokai-pro": {
background: "#2d2a2e",
foreground: "#fcfcfa",
cursor: "#fcfcfa",
cursorAccent: "#2d2a2e",
selectionBackground: "#5b595c",
selectionForeground: "#fcfcfa",
black: "#403e41",
red: "#ff6188",
green: "#a9dc76",
yellow: "#ffd866",
blue: "#78dce8",
magenta: "#ab9df2",
cyan: "#78dce8",
white: "#fcfcfa",
brightBlack: "#727072",
brightRed: "#ff6188",
brightGreen: "#a9dc76",
brightYellow: "#ffd866",
brightBlue: "#78dce8",
brightMagenta: "#ab9df2",
brightCyan: "#78dce8",
brightWhite: "#ffffff",
},
ristretto: {
background: "#2c2c2c",
foreground: "#eeeeee",
cursor: "#eeeeee",
cursorAccent: "#2c2c2c",
selectionBackground: "#7f7f7f",
selectionForeground: "#eeeeee",
black: "#2c2c2c",
red: "#cdaf95",
green: "#a8ff60",
yellow: "#bfbb1f",
blue: "#75a5b0",
magenta: "#ff73fd",
cyan: "#5ffdff",
white: "#b9b9b9",
brightBlack: "#545454",
brightRed: "#fcb08f",
brightGreen: "#a8ff60",
brightYellow: "#fffeb7",
brightBlue: "#a5c7ff",
brightMagenta: "#ff9cfe",
brightCyan: "#d5ffff",
brightWhite: "#ffffff",
},
dark: {
background: "#1e1e1e",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
cursorAccent: "#1e1e1e",
selectionBackground: "#264f78",
selectionForeground: "#d4d4d4",
black: "#000000",
red: "#cd3131",
green: "#0dbc79",
yellow: "#e5e510",
blue: "#2472c8",
magenta: "#bc3fbc",
cyan: "#11a8cd",
white: "#e5e5e5",
brightBlack: "#666666",
brightRed: "#f14c4c",
brightGreen: "#23d18b",
brightYellow: "#f5f543",
brightBlue: "#3b8eea",
brightMagenta: "#d670d6",
brightCyan: "#29b8db",
brightWhite: "#e5e5e5",
},
light: {
background: "#ffffff",
foreground: "#000000",
cursor: "#000000",
cursorAccent: "#ffffff",
selectionBackground: "#add6ff",
selectionForeground: "#000000",
black: "#000000",
red: "#cd3131",
green: "#00bc00",
yellow: "#949800",
blue: "#0451a5",
magenta: "#bc05bc",
cyan: "#0598bc",
white: "#555555",
brightBlack: "#666666",
brightRed: "#cd3131",
brightGreen: "#14ce14",
brightYellow: "#b5ba00",
brightBlue: "#0451a5",
brightMagenta: "#bc05bc",
brightCyan: "#0598bc",
brightWhite: "#a5a5a5",
},
dracula: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a",
selectionForeground: "#f8f8f2",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
catppuccin: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b70",
selectionForeground: "#cdd6f4",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
nord: {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "#4c566a",
selectionForeground: "#eceff4",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
},
gruvbox: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#504945",
selectionForeground: "#fbf1c7",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
solarized: {
background: "#002b36",
foreground: "#839496",
cursor: "#93a1a1",
cursorAccent: "#002b36",
selectionBackground: "#073642",
selectionForeground: "#93a1a1",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
tokyo: {
background: "#1a1b26",
foreground: "#c0caf5",
cursor: "#c0caf5",
cursorAccent: "#1a1b26",
selectionBackground: "#33467c",
selectionForeground: "#c0caf5",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
},
miasma: {
background: "#222222",
foreground: "#c2c2b0",
cursor: "#c2c2b0",
cursorAccent: "#222222",
selectionBackground: "#5f5f5f",
selectionForeground: "#ffffff",
black: "#222222",
red: "#685742",
green: "#5f875f",
yellow: "#b36d43",
blue: "#78824b",
magenta: "#bb7744",
cyan: "#c9a554",
white: "#c2c2b0",
brightBlack: "#666666",
brightRed: "#685742",
brightGreen: "#5f875f",
brightYellow: "#b36d43",
brightBlue: "#78824b",
brightMagenta: "#bb7744",
brightCyan: "#c9a554",
brightWhite: "#d7d7c7",
},
github: {
background: "#0d1117",
foreground: "#c9d1d9",
cursor: "#c9d1d9",
cursorAccent: "#0d1117",
selectionBackground: "#264f78",
selectionForeground: "#c9d1d9",
black: "#484f58",
red: "#ff7b72",
green: "#7ee787",
yellow: "#d29922",
blue: "#58a6ff",
magenta: "#bc8cff",
cyan: "#39c5cf",
white: "#b1bac4",
brightBlack: "#6e7681",
brightRed: "#ffa198",
brightGreen: "#56d364",
brightYellow: "#e3b341",
brightBlue: "#79c0ff",
brightMagenta: "#d2a8ff",
brightCyan: "#56d4dd",
brightWhite: "#f0f6fc",
},
gotham: {
background: "#0a0f14",
foreground: "#98d1ce",
cursor: "#98d1ce",
cursorAccent: "#0a0f14",
selectionBackground: "#1f2233",
selectionForeground: "#98d1ce",
black: "#0a0f14",
red: "#c33027",
green: "#26a98b",
yellow: "#edb54b",
blue: "#195465",
magenta: "#4e5165",
cyan: "#33859e",
white: "#98d1ce",
brightBlack: "#10151b",
brightRed: "#d26939",
brightGreen: "#081f2d",
brightYellow: "#245361",
brightBlue: "#093748",
brightMagenta: "#888ba5",
brightCyan: "#599caa",
brightWhite: "#d3ebe9",
},
};
File diff suppressed because one or more lines are too long
+1140 -1035
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
const SHELL_CACHE = 'webterm-shell-{{SHELL_VERSION}}';
const MODEL_CACHE = 'webterm-models-v1';
const SHELL_ASSETS = [
'/static/js/terminal.js?v={{SHELL_VERSION}}',
'/static/monospace.css',
'/static/fonts/FiraCodeNerdFont-Regular.ttf',
'/static/icons/webterm-192.png',
'/static/manifest.json',
];
const MODEL_PREFIXES = [
'/static/js/sherpa-moonshine',
'/static/js/ghostty-vt.wasm',
];
// Pre-cache shell assets; ignore network failures during install
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) =>
Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url)))
)
);
});
// Delete old shell caches, then claim all clients immediately
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k.startsWith('webterm-shell-') && k !== SHELL_CACHE)
.map((k) => caches.delete(k))
)
)
.then(() => self.clients.claim())
);
});
function isModelAsset(pathname) {
return MODEL_PREFIXES.some((p) => pathname.startsWith(p));
}
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Model files: large and stable, cache-first forever
if (isModelAsset(url.pathname)) {
event.respondWith(
caches.open(MODEL_CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
if (response.ok) cache.put(event.request, response.clone());
return response;
})
);
return;
}
// Static assets and navigation: stale-while-revalidate
// Serve from cache immediately; update cache in background
if (url.pathname.startsWith('/static/') || event.request.mode === 'navigate') {
event.respondWith(
caches.open(SHELL_CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
const networkFetch = fetch(event.request)
.then((response) => {
if (response.ok) cache.put(event.request, response.clone());
return response;
})
.catch(() => null);
if (cached) {
// Serve cache now; network update happens in background
networkFetch.catch(() => {});
return cached;
}
// Nothing cached yet — must wait for network
const response = await networkFetch;
if (response) return response;
// Offline and nothing cached: try root as fallback for navigation
if (event.request.mode === 'navigate') {
const root = await cache.match('/');
if (root) return root;
}
return Response.error();
})
);
}
});