Add theme/font CLI options and mobile Safari keyboard support

- Add --theme, --font-family, --font-size CLI options
- Pass theme/font config via HTML data attributes to frontend
- Add hidden textarea for mobile keyboard input capture
- Handle special keys (Enter, Backspace, arrows, Tab) on mobile
- Focus textarea on touch/click to trigger mobile keyboard

Bump version to 0.5.3
This commit is contained in:
GitHub Copilot
2026-01-28 07:25:04 +00:00
parent 38f0de907a
commit f4ca44c056
5 changed files with 156 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual-webterm"
version = "0.5.2"
version = "0.5.3"
description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"]
license = "MIT"
+25
View File
@@ -116,6 +116,25 @@ def load_app_class(app_path: str):
type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path),
help='Docker compose YAML; services with label "webterm-command" become landing tiles.',
)
@click.option(
"--theme",
"-t",
help="Terminal color theme (monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo).",
default="monokai",
)
@click.option(
"--font-family",
"-f",
help="Terminal font family (CSS font stack).",
default=None,
)
@click.option(
"--font-size",
"-s",
type=int,
help="Terminal font size in pixels.",
default=16,
)
def app(
command: str | None,
port: int,
@@ -123,6 +142,9 @@ def app(
app_path: str | None,
landing_manifest: Path | None,
compose_manifest: Path | None,
theme: str,
font_family: str | None,
font_size: int,
) -> None:
"""Serve a terminal or Textual app over HTTP/WebSocket.
@@ -165,6 +187,9 @@ def app(
landing_apps=landing_apps,
compose_mode=is_compose_mode,
compose_project=compose_project,
theme=theme,
font_family=font_family,
font_size=font_size,
)
for app_entry in landing_apps:
server.add_terminal(app_entry.name, app_entry.command, slug=app_entry.slug)
+14 -1
View File
@@ -124,9 +124,15 @@ class LocalServer:
landing_apps: list | None = None,
compose_mode: bool = False,
compose_project: str | None = None,
theme: str = "monokai",
font_family: str | None = None,
font_size: int = 16,
) -> None:
self.host = host
self.port = port
self.theme = theme
self.font_family = font_family
self.font_size = font_size
abs_path = Path(config_path).absolute()
path = abs_path if abs_path.is_dir() else abs_path.parent
@@ -842,6 +848,13 @@ class LocalServer:
ws_url = self._get_ws_url_from_request(request, route_key)
page_title = available_app.name if available_app else "Textual Web Terminal"
# Build data attributes for terminal configuration
data_attrs = f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" data-scrollback="1000" data-theme="{self.theme}"'
if self.font_family:
# Escape quotes for HTML attribute
escaped_font = self.font_family.replace('"', "&quot;")
data_attrs += f' data-font-family="{escaped_font}"'
html_content = f"""<!DOCTYPE html>
<html>
<head>
@@ -854,7 +867,7 @@ class LocalServer:
</style>
</head>
<body>
<div id=\"terminal\" class=\"textual-terminal\" data-session-websocket-url=\"{ws_url}\" data-font-size=\"16\" data-scrollback=\"1000\"></div>
<div id=\"terminal\" class=\"textual-terminal\" {data_attrs}></div>
<script type=\"module\" src=\"/static/js/terminal.js\"></script>
</body>
</html>"""
File diff suppressed because one or more lines are too long
+103
View File
@@ -292,6 +292,7 @@ class WebTerminal {
private reconnectDelay = 1000;
private messageQueue: [string, unknown][] = [];
private lastValidSize: { cols: number; rows: number } | null = null;
private mobileInput: HTMLTextAreaElement | null = null;
private constructor(
container: HTMLElement,
@@ -365,10 +366,108 @@ class WebTerminal {
}
});
// Setup mobile keyboard support
this.setupMobileKeyboard();
// Connect WebSocket
this.connect();
}
/** Setup mobile keyboard input via hidden textarea */
private setupMobileKeyboard(): void {
// Create hidden textarea for mobile keyboard input
const textarea = document.createElement("textarea");
textarea.setAttribute("autocapitalize", "off");
textarea.setAttribute("autocomplete", "off");
textarea.setAttribute("autocorrect", "off");
textarea.setAttribute("spellcheck", "false");
textarea.setAttribute("inputmode", "text");
textarea.setAttribute("enterkeyhint", "send");
// Style to be invisible but still focusable (not display:none)
textarea.style.cssText = `
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 1px;
opacity: 0;
z-index: -1;
pointer-events: none;
font-size: 16px;
`;
// Font size 16px prevents iOS auto-zoom on focus
this.element.style.position = "relative";
this.element.appendChild(textarea);
this.mobileInput = textarea;
// Handle input from mobile keyboard
textarea.addEventListener("input", () => {
const value = textarea.value;
if (value) {
this.send(["stdin", value]);
textarea.value = "";
}
});
// Handle special keys via keydown
textarea.addEventListener("keydown", (e) => {
let seq: string | null = null;
switch (e.key) {
case "Enter":
seq = "\r";
break;
case "Backspace":
seq = "\x7f";
break;
case "Escape":
seq = "\x1b";
break;
case "ArrowUp":
seq = "\x1b[A";
break;
case "ArrowDown":
seq = "\x1b[B";
break;
case "ArrowRight":
seq = "\x1b[C";
break;
case "ArrowLeft":
seq = "\x1b[D";
break;
case "Tab":
seq = "\t";
e.preventDefault();
break;
}
if (seq) {
e.preventDefault();
this.send(["stdin", seq]);
}
});
// Focus textarea on touch/click to show mobile keyboard
this.element.addEventListener("touchstart", () => {
this.focusMobileInput();
}, { passive: true });
this.element.addEventListener("click", () => {
this.focusMobileInput();
});
}
/** Focus the mobile input to show keyboard */
private focusMobileInput(): void {
if (this.mobileInput) {
// Small delay helps with iOS keyboard activation
setTimeout(() => {
this.mobileInput?.focus({ preventScroll: true });
}, 10);
}
// Also focus the terminal for desktop
this.terminal.focus();
}
/** Wait for fonts to be loaded */
private async waitForFonts(): Promise<void> {
if (!("fonts" in document)) {
@@ -507,6 +606,10 @@ class WebTerminal {
/** Clean up resources */
dispose(): void {
this.socket?.close();
if (this.mobileInput) {
this.mobileInput.remove();
this.mobileInput = null;
}
this.fitAddon.dispose();
this.terminal.dispose();
}