mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-06 16:02:05 +00:00
The phone can now hand a pane a photo or file, read what an agent changed, answer its prompts with a tap, and keep its link across leaving a pane. Gateway and protocol: - `Open::Upload` puts a file (up to 20 MB) in the directory the desktop keeps pasted images in, under a name a shell takes unquoted, and answers with its path. Panes on this machine only for now. - `Open::Diff` answers with the working tree's changes against HEAD and its untracked files, for the directory a pane is in. - Both are one-shot streams; an older gateway drops them unanswered, which the client reports as "update tty7". App: - Attach button in the message box: the uploaded path goes into the draft. - Changes sheet from a pane's menu: a block per file, lines coloured. - Settings → Key bar: remove, reorder and add keys, from a catalog or written out (`/compact\r`, `^C`), across pages; reset to the default. - An agent's numbered choices show as buttons while it waits. - Pinch to zoom, tap a link to open it, find in the scrollback, a Copy button for text a program copies (OSC 52), and an optional Face ID lock. - Swipe from the left edge to go back; the iOS WebView has no page stack. - One workspace at a time on a machine, picked from a row of chips. - Agent avatars in their own colours. - Each screen's watch is its own: a second watch no longer ends the first, which showed as "Can't reach" and a reconnect after leaving a pane. - Dialing no longer holds the session lock, so Forget works while a machine is still connecting; its confirmation is an in-app sheet, since `window.confirm` shows nothing in the iOS WebView. - iOS 15 minimum, as App Store Connect will require from April 2027. - `scripts/testflight.sh` archives, signs for the App Store at export (the team has no devices for a development profile) and uploads. Co-authored-by: Claude <noreply@anthropic.com>
3125 lines
113 KiB
TypeScript
3125 lines
113 KiB
TypeScript
import "@xterm/xterm/css/xterm.css";
|
||
import "./style.css";
|
||
|
||
import { Terminal } from "@xterm/xterm";
|
||
import { WebglAddon } from "@xterm/addon-webgl";
|
||
import { SearchAddon } from "@xterm/addon-search";
|
||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||
import { authenticate, checkStatus } from "@tauri-apps/plugin-biometric";
|
||
import type { ITheme } from "@xterm/xterm";
|
||
import * as scanner from "@tauri-apps/plugin-barcode-scanner";
|
||
|
||
import { getVersion } from "@tauri-apps/api/app";
|
||
|
||
import * as api from "./api";
|
||
import { MAX_UPLOAD } from "./api";
|
||
import type {
|
||
AgentStatus,
|
||
AgentView,
|
||
Host,
|
||
LinkInfo,
|
||
PaneView,
|
||
RemoteView,
|
||
TabView,
|
||
Tree,
|
||
WorkspaceView,
|
||
} from "./api";
|
||
import { agentLook, icon } from "./icons";
|
||
import logoUrl from "./assets/logo.svg?url";
|
||
|
||
const app = document.getElementById("app")!;
|
||
|
||
// The keyboard. The WebView runs edge to edge and is never resized for it
|
||
// (lib.rs `edge_to_edge`): the keyboard simply covers the bottom of the page.
|
||
// What is left is the visual viewport, so the app is sized to that, and the
|
||
// dock and the message box sit on top of the keyboard. Screens that lay out
|
||
// by size hear it as a window resize.
|
||
{
|
||
const view = window.visualViewport;
|
||
let last = 0;
|
||
const fitView = () => {
|
||
if (!view) return;
|
||
const height = Math.round(view.height);
|
||
// iOS scrolls the page to show a focused field; the app does its own.
|
||
if (window.scrollY) window.scrollTo(0, 0);
|
||
if (height === last) return;
|
||
last = height;
|
||
const up = height < window.innerHeight - 80;
|
||
document.documentElement.classList.toggle("keyboard", up);
|
||
app.style.height = up ? `${height}px` : "";
|
||
// Sheets, outside the app, stand on the keyboard too.
|
||
document.documentElement.style.setProperty("--keyboard", up ? `${window.innerHeight - height}px` : "0px");
|
||
window.dispatchEvent(new CustomEvent("viewport"));
|
||
};
|
||
view?.addEventListener("resize", fitView);
|
||
view?.addEventListener("scroll", fitView);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// A tiny DOM helper. Four screens do not justify a framework, and the
|
||
// terminal — the one heavy view — is xterm.js either way.
|
||
|
||
type Child = Node | string | null | undefined | false;
|
||
|
||
function h<K extends keyof HTMLElementTagNameMap>(
|
||
tag: K,
|
||
props: Partial<HTMLElementTagNameMap[K]> & { class?: string } = {},
|
||
...children: Child[]
|
||
): HTMLElementTagNameMap[K] {
|
||
const el = document.createElement(tag);
|
||
const { class: cls, ...rest } = props;
|
||
if (cls) el.className = cls;
|
||
Object.assign(el, rest);
|
||
for (const c of children) if (c) el.append(c);
|
||
return el;
|
||
}
|
||
|
||
/** One of the drawn icons, as an element. */
|
||
function ico(name: keyof typeof icon, cls = "icon") {
|
||
const el = h("span", { class: cls });
|
||
el.innerHTML = icon[name];
|
||
return el;
|
||
}
|
||
|
||
/** An error from the Rust side, read as a sentence. */
|
||
function sentence(text: string) {
|
||
const t = text.trim();
|
||
if (!t) return "";
|
||
return `${t[0].toUpperCase()}${t.slice(1)}${/[.!?]$/.test(t) ? "" : "."}`;
|
||
}
|
||
|
||
function errorText(e: unknown) {
|
||
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Preferences, kept on the phone. Appearance applies at once; the rest is read
|
||
// as a terminal opens.
|
||
|
||
type Appearance = "system" | "light" | "dark";
|
||
|
||
interface Prefs {
|
||
appearance: Appearance;
|
||
/** The terminal's font size when it is not shrunk to fit, in px. */
|
||
textSize: number;
|
||
/** How a pane wider than the phone first shows: panning, or shrunk. */
|
||
wide: "readable" | "fit";
|
||
/** Face ID (or the passcode) before the app shows, on launch and after a
|
||
* minute away. */
|
||
lock: boolean;
|
||
}
|
||
|
||
const PREFS: Prefs = { appearance: "system", textSize: 11, wide: "readable", lock: false };
|
||
|
||
const prefs: Prefs = (() => {
|
||
try {
|
||
return { ...PREFS, ...JSON.parse(remembered("prefs") ?? "{}") };
|
||
} catch {
|
||
return { ...PREFS };
|
||
}
|
||
})();
|
||
|
||
function setPref<K extends keyof Prefs>(key: K, value: Prefs[K]) {
|
||
prefs[key] = value;
|
||
remember("prefs", JSON.stringify(prefs));
|
||
if (key === "appearance") applyAppearance();
|
||
}
|
||
|
||
const systemDark = matchMedia("(prefers-color-scheme: dark)");
|
||
const themeColor = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
|
||
let shownDark: boolean | null = null;
|
||
|
||
function isDark() {
|
||
return prefs.appearance === "system" ? systemDark.matches : prefs.appearance === "dark";
|
||
}
|
||
|
||
/** Draws the page light or dark; a change is heard as a `theme` event. */
|
||
function paintAppearance() {
|
||
const dark = isDark();
|
||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||
themeColor?.setAttribute("content", dark ? "#0f0f10" : "#f3f3f1");
|
||
if (dark === shownDark) return;
|
||
shownDark = dark;
|
||
window.dispatchEvent(new CustomEvent("theme"));
|
||
}
|
||
|
||
function applyAppearance() {
|
||
paintAppearance();
|
||
api.appearance(prefs.appearance).catch(() => {});
|
||
}
|
||
|
||
systemDark.addEventListener("change", paintAppearance);
|
||
applyAppearance();
|
||
|
||
// What was sent from the message box, newest first, across every machine: a
|
||
// command typed on one is as likely on the next.
|
||
const HISTORY_MAX = 200;
|
||
|
||
function sentHistory(): string[] {
|
||
try {
|
||
const list = JSON.parse(remembered("history") ?? "[]");
|
||
return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function keepSent(text: string) {
|
||
const t = text.trim();
|
||
if (!t) return;
|
||
remember("history", JSON.stringify([t, ...sentHistory().filter((x) => x !== t)].slice(0, HISTORY_MAX)));
|
||
}
|
||
|
||
/** Past messages for what is being written: those it begins, then those
|
||
* that contain it. */
|
||
function suggestions(typed: string, limit = 12) {
|
||
const q = typed.trim().toLowerCase();
|
||
if (!q) return [];
|
||
const starts: string[] = [];
|
||
const within: string[] = [];
|
||
for (const past of sentHistory()) {
|
||
const low = past.toLowerCase();
|
||
if (low === q) continue;
|
||
if (low.startsWith(q)) starts.push(past);
|
||
else if (low.includes(q)) within.push(past);
|
||
}
|
||
return [...starts, ...within].slice(0, limit);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Navigation. Every screen is pushed or popped: the new one slides in over the
|
||
// old, the way a phone's own apps move, and back undoes it.
|
||
|
||
// Each screen registers what to do when the app comes back from the
|
||
// background, and how to let go of its streams when it is left.
|
||
let onResume: (() => void) | null = null;
|
||
let onLeave: (() => void) | null = null;
|
||
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState === "visible") {
|
||
if (prefs.lock && Date.now() - hiddenAt > LOCK_AFTER_MS) lock();
|
||
onResume?.();
|
||
} else hiddenAt = Date.now();
|
||
});
|
||
|
||
// The app lock: a cover over everything until Face ID or the passcode says
|
||
// it is the phone's owner. Streams go on underneath; nothing shows.
|
||
const LOCK_AFTER_MS = 60_000;
|
||
let hiddenAt = Date.now();
|
||
let cover: HTMLElement | null = null;
|
||
function lock() {
|
||
if (cover) return;
|
||
const unlock = h("button", { class: "button primary" }, "Unlock");
|
||
cover = h(
|
||
"div",
|
||
{ class: "lock-cover" },
|
||
h("img", { class: "empty-mark", src: logoUrl, alt: "" }),
|
||
h("h2", { class: "empty-title" }, "tty7 is locked"),
|
||
unlock,
|
||
);
|
||
const tryUnlock = async () => {
|
||
try {
|
||
await authenticate("Unlock tty7", { allowDeviceCredential: true });
|
||
cover?.remove();
|
||
cover = null;
|
||
} catch {
|
||
// Cancelled or failed: the cover stays, Unlock tries again.
|
||
}
|
||
};
|
||
unlock.onclick = tryUnlock;
|
||
document.body.append(cover);
|
||
void tryUnlock();
|
||
}
|
||
|
||
/** Whether this phone can lock the app: Face ID, Touch ID or a passcode. */
|
||
async function canLock() {
|
||
try {
|
||
return (await checkStatus()).isAvailable;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const still = matchMedia("(prefers-reduced-motion: reduce)");
|
||
|
||
function go(direction: "push" | "pop", render: () => HTMLElement) {
|
||
const swap = () => {
|
||
onLeave?.();
|
||
onLeave = null;
|
||
onResume = null;
|
||
onBack = null;
|
||
app.replaceChildren(render());
|
||
};
|
||
if (!document.startViewTransition || still.matches || !app.firstChild) {
|
||
swap();
|
||
return;
|
||
}
|
||
document.documentElement.dataset.nav = direction;
|
||
document
|
||
.startViewTransition(swap)
|
||
.finished.finally(() => delete document.documentElement.dataset.nav);
|
||
}
|
||
|
||
// Swiping in from the left edge goes back, the way a phone's own apps do. The
|
||
// WebView has no stack of pages to do it natively — each screen is swapped in
|
||
// place (`go`) — so it is done here. Each pushed screen sets its way back.
|
||
let onBack: (() => void) | null = null;
|
||
const EDGE = 24;
|
||
let edgeSwipe: { x: number; y: number; t: number; claimed: boolean } | null = null;
|
||
// Listened for ahead of every screen's own touch handling, so the terminal's
|
||
// scrolling and a wide pane's sideways pan never see an edge swipe.
|
||
document.addEventListener(
|
||
"touchstart",
|
||
(e) => {
|
||
edgeSwipe = null;
|
||
if (!onBack || e.touches.length !== 1) return;
|
||
// Not from under a sheet: the scrim covers the screen it would leave.
|
||
if (!app.contains(e.target as Node)) return;
|
||
const t = e.touches[0];
|
||
if (t.clientX > EDGE) return;
|
||
edgeSwipe = { x: t.clientX, y: t.clientY, t: e.timeStamp, claimed: false };
|
||
},
|
||
{ capture: true, passive: true },
|
||
);
|
||
document.addEventListener(
|
||
"touchmove",
|
||
(e) => {
|
||
if (!edgeSwipe || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
const dx = t.clientX - edgeSwipe.x;
|
||
const dy = Math.abs(t.clientY - edgeSwipe.y);
|
||
if (!edgeSwipe.claimed) {
|
||
if (Math.max(Math.abs(dx), dy) < 8) return;
|
||
// Mostly sideways and rightwards, it is a swipe back; anything else is
|
||
// the screen's own.
|
||
if (dx <= dy) return (edgeSwipe = null);
|
||
edgeSwipe.claimed = true;
|
||
}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
},
|
||
{ capture: true, passive: false },
|
||
);
|
||
document.addEventListener(
|
||
"touchend",
|
||
(e) => {
|
||
const swipe = edgeSwipe;
|
||
edgeSwipe = null;
|
||
if (!swipe?.claimed) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const t = e.changedTouches[0];
|
||
const dx = t.clientX - swipe.x;
|
||
// Far enough across, or flicked.
|
||
if (dx > Math.min(80, innerWidth * 0.25) || dx / Math.max(1, e.timeStamp - swipe.t) > 0.4) onBack?.();
|
||
},
|
||
{ capture: true, passive: false },
|
||
);
|
||
document.addEventListener("touchcancel", () => (edgeSwipe = null), { capture: true, passive: true });
|
||
|
||
interface ScreenParts {
|
||
title: string;
|
||
back?: { label: string; onclick: () => void };
|
||
trailing?: Child[];
|
||
/** Under the large title: the connection line on a machine. */
|
||
subtitle?: Child;
|
||
body: Child[];
|
||
footer?: Child;
|
||
/** Floats over the bottom of the list: search and the one "+". */
|
||
dock?: Child;
|
||
}
|
||
|
||
/** A screen with a large title that folds into the bar as it scrolls away. */
|
||
function screen(parts: ScreenParts) {
|
||
const root = !parts.back;
|
||
onBack = parts.back?.onclick ?? null;
|
||
const bar = h(
|
||
"header",
|
||
{ class: "nav" },
|
||
h(
|
||
"div",
|
||
{ class: "nav-lead" },
|
||
parts.back &&
|
||
h(
|
||
"button",
|
||
{ class: "nav-back", onclick: parts.back.onclick, ariaLabel: `Back to ${parts.back.label}` },
|
||
ico("back"),
|
||
h("span", {}, parts.back.label),
|
||
),
|
||
),
|
||
h("div", { class: "nav-title" }, parts.title),
|
||
h("div", { class: "nav-trail" }, ...(root ? [] : (parts.trailing ?? []))),
|
||
);
|
||
const large = h("h1", { class: "large-title" }, parts.title);
|
||
// A top-level screen keeps its buttons beside the large title, not in a
|
||
// bar of their own above it.
|
||
const heading = root && parts.trailing?.length
|
||
? h("div", { class: "title-row" }, large, h("div", { class: "title-trail" }, ...parts.trailing))
|
||
: large;
|
||
const scroll = h(
|
||
"main",
|
||
{ class: "scroll" },
|
||
h("div", { class: "title-block" }, heading, parts.subtitle),
|
||
...parts.body,
|
||
);
|
||
if (root) {
|
||
// The bar floats over a top-level screen, empty until the title has
|
||
// scrolled up under it.
|
||
scroll.addEventListener(
|
||
"scroll",
|
||
() => bar.classList.toggle("folded", scroll.scrollTop > large.offsetTop + large.offsetHeight - bar.offsetHeight),
|
||
{ passive: true },
|
||
);
|
||
} else {
|
||
new IntersectionObserver(
|
||
([entry]) => bar.classList.toggle("folded", !entry.isIntersecting),
|
||
{ root: scroll, threshold: 0, rootMargin: "-8px 0px 0px 0px" },
|
||
).observe(large);
|
||
}
|
||
const view = h("div", { class: "screen" }, bar, scroll, parts.footer, parts.dock);
|
||
if (parts.dock) view.classList.add("docked");
|
||
if (root) view.classList.add("root");
|
||
return view;
|
||
}
|
||
|
||
/** The bar floating at the bottom of a list: a search field, and the screen's
|
||
* one way to add something, within the thumb's reach. */
|
||
function floatingBar(placeholder: string, onSearch: (query: string) => void, add: { label: string; run: () => void }) {
|
||
const field = h("input", {
|
||
type: "search",
|
||
class: "search-input",
|
||
placeholder,
|
||
enterKeyHint: "search",
|
||
autocapitalize: "off",
|
||
spellcheck: false,
|
||
ariaLabel: placeholder,
|
||
});
|
||
field.setAttribute("autocorrect", "off");
|
||
field.oninput = () => onSearch(field.value.trim().toLowerCase());
|
||
field.onkeydown = (e) => {
|
||
if (e.key === "Enter") field.blur();
|
||
};
|
||
return h(
|
||
"div",
|
||
{ class: "float-bar" },
|
||
h("label", { class: "search" }, ico("search"), field),
|
||
h("button", { class: "fab", ariaLabel: add.label, onclick: add.run }, ico("plus")),
|
||
);
|
||
}
|
||
|
||
function section(title: Child, ...rows: Child[]) {
|
||
return h(
|
||
"section",
|
||
{ class: "group" },
|
||
title && h("h2", { class: "group-title" }, title),
|
||
h("div", { class: "card" }, ...rows),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Machines
|
||
|
||
/** What the last visit to each machine saw: its link and how many tabs it
|
||
* had. The list shows it rather than holding a stream open per machine. */
|
||
const seen = new Map<string, { link: LinkInfo | null; tabs: number | null }>();
|
||
|
||
function hostMeta(hostId: string): { tone: string; text: string } | null {
|
||
const last = seen.get(hostId);
|
||
if (!last) return null;
|
||
const tabs = last.tabs === null ? "" : ` · ${last.tabs} ${last.tabs === 1 ? "tab" : "tabs"}`;
|
||
if (last.link === null) return { tone: "offline", text: "Offline" };
|
||
if (last.link.path === "connecting") return { tone: "connecting", text: `Connecting…${tabs}` };
|
||
const path = last.link.path === "direct" ? "Direct" : "Relay";
|
||
return { tone: last.link.path, text: `${path} · ${last.link.rtt_ms} ms${tabs}` };
|
||
}
|
||
|
||
function hostsScreen(direction: "push" | "pop" = "pop") {
|
||
go(direction, () => {
|
||
const body = h("div", { class: "stack" });
|
||
let all: Host[] = [];
|
||
let query = "";
|
||
const dock = floatingBar("Search machines", (q) => {
|
||
query = q;
|
||
render();
|
||
}, { label: "Pair a machine", run: () => pairScreen() });
|
||
dock.hidden = true;
|
||
const view = screen({
|
||
title: "Machines",
|
||
trailing: [h("button", { class: "nav-icon", ariaLabel: "Settings", onclick: () => settingsScreen() }, ico("settings"))],
|
||
body: [body],
|
||
dock,
|
||
});
|
||
|
||
const render = () => {
|
||
const shown = all.filter((host) => host.name.toLowerCase().includes(query));
|
||
body.replaceChildren(
|
||
shown.length
|
||
? section(null, ...shown.map(hostRow))
|
||
: h("p", { class: "search-empty" }, `No machine matches “${query}”.`),
|
||
);
|
||
};
|
||
api.hosts().then((hosts) => {
|
||
all = hosts;
|
||
if (hosts.length === 0) {
|
||
body.replaceChildren(
|
||
h(
|
||
"div",
|
||
{ class: "empty" },
|
||
h("img", { class: "empty-mark", src: logoUrl, alt: "" }),
|
||
h("h2", { class: "empty-title" }, "Pair your computer"),
|
||
h(
|
||
"p",
|
||
{ class: "empty-body" },
|
||
"Reach the panes open in tty7 on your computer, and type into them from here.",
|
||
),
|
||
h("button", { class: "button primary", onclick: () => pairScreen() }, "Pair a machine"),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
dock.hidden = false;
|
||
render();
|
||
});
|
||
return view;
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Settings
|
||
|
||
const APPEARANCES: { value: Appearance; label: string }[] = [
|
||
{ value: "system", label: "Automatic" },
|
||
{ value: "light", label: "Light" },
|
||
{ value: "dark", label: "Dark" },
|
||
];
|
||
|
||
const TEXT_SIZES = [
|
||
{ value: 10, label: "Small" },
|
||
{ value: 11, label: "Default" },
|
||
{ value: 13, label: "Large" },
|
||
{ value: 15, label: "Larger" },
|
||
];
|
||
|
||
const WIDE = [
|
||
{ value: "readable", label: "Keep the text readable", meta: "Pan sideways" },
|
||
{ value: "fit", label: "Fit the whole width", meta: "Smaller text" },
|
||
] as const;
|
||
|
||
function settingsScreen() {
|
||
go("push", () => {
|
||
const body = h("div", { class: "stack" });
|
||
const version = h("span", { class: "row-meta" });
|
||
let lockable = false;
|
||
void canLock().then((ok) => {
|
||
lockable = ok;
|
||
if (ok) draw();
|
||
});
|
||
void getVersion().then((v) => (version.textContent = v), () => {});
|
||
|
||
/** One setting's options, as rows with a check on the chosen one. */
|
||
const choices = <T,>(options: readonly { value: T; label: string; meta?: string }[], chosen: T, pick: (v: T) => void, sample?: (v: T) => Child) =>
|
||
options.map((o) =>
|
||
h(
|
||
"button",
|
||
{ class: "row choice", onclick: () => (pick(o.value), draw()) },
|
||
h("span", { class: "row-title" }, o.label),
|
||
sample ? sample(o.value) : o.meta && h("span", { class: "row-meta" }, o.meta),
|
||
o.value === chosen ? ico("check", "icon choice-check") : h("span", { class: "choice-check" }),
|
||
),
|
||
);
|
||
const note = (group: HTMLElement, text: string) => (group.append(h("p", { class: "group-note" }, text)), group);
|
||
|
||
const lockSetting = () =>
|
||
note(
|
||
section(
|
||
"Privacy",
|
||
h(
|
||
"button",
|
||
{
|
||
class: "row choice",
|
||
onclick: async () => {
|
||
// Turning it on proves it works first, so no one locks
|
||
// themselves out.
|
||
if (!prefs.lock) {
|
||
try {
|
||
await authenticate("Turn on the tty7 lock", { allowDeviceCredential: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
setPref("lock", !prefs.lock);
|
||
draw();
|
||
},
|
||
},
|
||
h("span", { class: "row-title" }, "Lock with Face ID"),
|
||
h("span", { class: prefs.lock ? "switch on" : "switch" }),
|
||
),
|
||
),
|
||
"Asks for Face ID or the passcode when tty7 opens, and when you come back after a minute away.",
|
||
);
|
||
const draw = () => {
|
||
const saved = sentHistory().length;
|
||
const clear = h(
|
||
"button",
|
||
{ class: "row choice", disabled: saved === 0 },
|
||
h("span", { class: saved ? "row-title danger" : "row-title" }, "Clear message history"),
|
||
h("span", { class: "row-meta" }, saved ? `${saved} saved` : "Empty"),
|
||
);
|
||
clear.onclick = () => {
|
||
remember("history", "[]");
|
||
draw();
|
||
};
|
||
body.replaceChildren(
|
||
section("Appearance", ...choices(APPEARANCES, prefs.appearance, (v) => setPref("appearance", v))),
|
||
note(
|
||
section(
|
||
"Terminal text size",
|
||
...choices(TEXT_SIZES, prefs.textSize, (v) => setPref("textSize", v), (v) => {
|
||
const aa = h("span", { class: "row-meta size-sample" }, "~/tty7 $");
|
||
aa.style.fontSize = `${v}px`;
|
||
return aa;
|
||
}),
|
||
),
|
||
"The size panes are read at. Applies to the next pane you open.",
|
||
),
|
||
note(
|
||
section("Panes wider than the phone", ...choices(WIDE, prefs.wide, (v) => setPref("wide", v))),
|
||
"How such a pane first shows. Switch any time from its ⋯ menu.",
|
||
),
|
||
note(
|
||
section(
|
||
"Key bar",
|
||
h(
|
||
"button",
|
||
{ class: "row choice", onclick: () => keysScreen() },
|
||
h("span", { class: "row-title" }, "Keys above the message box"),
|
||
h("span", { class: "row-meta" }, remembered("keys") && remembered("keys") !== "null" ? "Custom" : "Default"),
|
||
ico("chevron", "icon row-chevron"),
|
||
),
|
||
),
|
||
"Choose, order and add the keys a pane shows. A key can send any text, so a prompt you use often can be one tap.",
|
||
),
|
||
...(lockable ? [lockSetting()] : []),
|
||
note(
|
||
section("Message history", clear),
|
||
"What you send from the message box is kept on this phone, to suggest as you write and to search from the history button.",
|
||
),
|
||
section(
|
||
"About",
|
||
h("div", { class: "row choice static" }, h("span", { class: "row-title" }, "Version"), version),
|
||
),
|
||
);
|
||
};
|
||
draw();
|
||
return screen({
|
||
title: "Settings",
|
||
back: { label: "Machines", onclick: () => hostsScreen() },
|
||
body: [body],
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Settings → Key bar: the key row's pages, each key removable and movable,
|
||
* keys added from a catalog or written out. */
|
||
function keysScreen() {
|
||
go("push", () => {
|
||
let pages = keyPages().map((p) => [...p]);
|
||
const body = h("div", { class: "stack" });
|
||
const save = () => {
|
||
pages = pages.filter((p, i) => p.length || i === pages.length - 1);
|
||
if (!pages.length) pages = [[]];
|
||
saveKeyPages(pages);
|
||
draw();
|
||
};
|
||
// Up past a page's first key goes to the end of the page before; down
|
||
// past its last, to the start of the next — when that page has room.
|
||
const move = (pi: number, ki: number, by: -1 | 1) => {
|
||
const page = pages[pi];
|
||
const to = ki + by;
|
||
if (to >= 0 && to < page.length) {
|
||
[page[ki], page[to]] = [page[to], page[ki]];
|
||
} else {
|
||
const other = pages[pi + by];
|
||
if (!other || other.length >= KEYS_PER_PAGE) return;
|
||
const [k] = page.splice(ki, 1);
|
||
if (by < 0) other.push(k);
|
||
else other.unshift(k);
|
||
}
|
||
save();
|
||
};
|
||
const draw = () => {
|
||
const groups = pages.map((page, pi) => {
|
||
const rows = page.map((k, ki) => {
|
||
const first = pi === 0 && ki === 0;
|
||
const last = pi === pages.length - 1 && ki === page.length - 1;
|
||
return h(
|
||
"div",
|
||
{ class: "row key-edit" },
|
||
h("span", { class: "key key-sample" }, keyFace(k)),
|
||
h("span", { class: "row-title" }, k.latch ? "ctrl (hold for the next key)" : k.paste ? "Paste" : k.label),
|
||
h("button", { class: "nav-icon", ariaLabel: `Move ${k.label} up`, disabled: first, onclick: () => move(pi, ki, -1) }, ico("up")),
|
||
h("button", { class: "nav-icon", ariaLabel: `Move ${k.label} down`, disabled: last, onclick: () => move(pi, ki, 1) }, ico("down")),
|
||
h(
|
||
"button",
|
||
{ class: "nav-icon danger", ariaLabel: `Remove ${k.label}`, onclick: () => (page.splice(ki, 1), save()) },
|
||
ico("trash"),
|
||
),
|
||
);
|
||
});
|
||
const full = page.length >= KEYS_PER_PAGE;
|
||
const add = h(
|
||
"button",
|
||
{ class: "row choice", disabled: full, onclick: () => addKeySheet((k) => (page.push(k), save())) },
|
||
h("span", { class: "row-title accent" }, full ? "This page is full" : "Add a key"),
|
||
ico("plus", "icon row-chevron"),
|
||
);
|
||
return section(`Page ${pi + 1}`, ...rows, add);
|
||
});
|
||
const more = h(
|
||
"button",
|
||
{
|
||
class: "row choice",
|
||
disabled: pages.length >= MAX_KEY_PAGES || pages[pages.length - 1].length === 0,
|
||
onclick: () => (pages.push([]), draw()),
|
||
},
|
||
h("span", { class: "row-title accent" }, "Add a page"),
|
||
);
|
||
const reset = h(
|
||
"button",
|
||
{
|
||
class: "row choice",
|
||
onclick: () => {
|
||
saveKeyPages(null);
|
||
pages = KEY_PAGES.map((p) => [...p]);
|
||
draw();
|
||
},
|
||
},
|
||
h("span", { class: "row-title danger" }, "Reset to the default keys"),
|
||
);
|
||
body.replaceChildren(
|
||
...groups,
|
||
section(null, more, reset),
|
||
h("p", { class: "group-note keys-note" }, "Swipe the key row sideways in a pane to reach the next page."),
|
||
);
|
||
};
|
||
draw();
|
||
return screen({
|
||
title: "Key bar",
|
||
back: { label: "Settings", onclick: () => settingsScreen() },
|
||
body: [body],
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Picks a key to add: one of the catalog's, or one written out. */
|
||
function addKeySheet(add: (k: Key) => void) {
|
||
const grid = h(
|
||
"div",
|
||
{ class: "key-catalog" },
|
||
...KEY_CATALOG.map((k) =>
|
||
h("button", { class: "key", ariaLabel: k.label, onclick: () => (sheet.close(), add(k)) }, keyFace(k)),
|
||
),
|
||
);
|
||
const label = h("input", { class: "field-input", placeholder: "Label, e.g. compact", ariaLabel: "Label" });
|
||
const sends = h("input", {
|
||
class: "field-input code",
|
||
placeholder: "Sends, e.g. /compact\\r",
|
||
autocapitalize: "off",
|
||
spellcheck: false,
|
||
ariaLabel: "What it sends",
|
||
});
|
||
for (const f of [label, sends]) f.setAttribute("autocorrect", "off");
|
||
const error = h("p", { class: "field-error", role: "alert" });
|
||
const make = h("button", { class: "button primary wide" }, "Add key");
|
||
make.onclick = () => {
|
||
const seq = keySequence(sends.value);
|
||
if (!seq) {
|
||
error.textContent = "Write what the key sends.";
|
||
return;
|
||
}
|
||
const name = label.value.trim() || sends.value.trim();
|
||
sheet.close();
|
||
add({ label: name, seq, flex: Math.min(2, Math.max(1, name.length / 4)) });
|
||
};
|
||
const sheet = openSheet(
|
||
"Add a key",
|
||
h(
|
||
"div",
|
||
{ class: "sheet-body" },
|
||
h("section", { class: "sheet-group" }, h("h3", { class: "group-title" }, "Keys"), grid),
|
||
h(
|
||
"section",
|
||
{ class: "sheet-group" },
|
||
h("h3", { class: "group-title" }, "Your own"),
|
||
h("div", { class: "card form-card" }, h("label", { class: "row field" }, label), h("label", { class: "row field" }, sends)),
|
||
h("p", { class: "group-note" }, "Text as written. ^C for ctrl-C; \\r for Enter, \\e for Esc, \\t for Tab."),
|
||
error,
|
||
make,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function hostRow(host: Host) {
|
||
const meta = hostMeta(host.id);
|
||
return h(
|
||
"button",
|
||
{ class: meta?.tone === "offline" ? "row machine dim" : "row machine", onclick: () => hostScreen(host, "push") },
|
||
h("span", { class: "tile" }, ico("machine")),
|
||
h(
|
||
"span",
|
||
{ class: "row-text" },
|
||
h("span", { class: "row-title" }, host.name),
|
||
meta && h("span", { class: `row-sub link ${meta.tone}` }, h("span", { class: "link-dot" }), meta.text),
|
||
),
|
||
ico("chevron", "icon row-chevron"),
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pairing
|
||
|
||
/** Only a phone has a camera to point: the desktop dev build pastes. */
|
||
const canScan = /iPhone|iPad|Android/.test(navigator.userAgent);
|
||
|
||
class CameraDenied extends Error {}
|
||
|
||
/** Reads a QR code with the camera. The camera runs behind the WebView, so
|
||
* the page goes transparent except for a viewfinder and a Cancel button (the
|
||
* plugin's own full-screen view has no way out). Null when cancelled. */
|
||
async function scanCode(): Promise<string | null> {
|
||
let state = await scanner.checkPermissions();
|
||
if (state !== "granted" && state !== "denied") state = await scanner.requestPermissions();
|
||
if (state !== "granted") throw new CameraDenied();
|
||
|
||
const overlay = h(
|
||
"div",
|
||
{ class: "scanner" },
|
||
h("div", { class: "scanner-frame" }),
|
||
h("p", { class: "scanner-hint" }, "Point at the QR code in tty7 on your computer"),
|
||
h("button", { class: "button scanner-cancel", onclick: () => void scanner.cancel() }, "Cancel"),
|
||
);
|
||
document.documentElement.classList.add("scanning");
|
||
document.body.append(overlay);
|
||
try {
|
||
return (await scanner.scan({ windowed: true, formats: [scanner.Format.QRCode] })).content;
|
||
} catch (e) {
|
||
if (/cancel/i.test(errorText(e))) return null;
|
||
throw e;
|
||
} finally {
|
||
overlay.remove();
|
||
document.documentElement.classList.remove("scanning");
|
||
}
|
||
}
|
||
|
||
function pairScreen() {
|
||
go("push", () => {
|
||
const code = h("textarea", {
|
||
class: "field-input code",
|
||
placeholder: "tty7pair:…",
|
||
rows: 4,
|
||
autocapitalize: "off",
|
||
spellcheck: false,
|
||
ariaLabel: "Pairing code",
|
||
});
|
||
const name = h("input", {
|
||
class: "field-input",
|
||
value: guessDeviceName(),
|
||
placeholder: "This phone",
|
||
ariaLabel: "This phone's name",
|
||
autocapitalize: "words",
|
||
});
|
||
const error = h("p", { class: "field-error", role: "alert" });
|
||
const submit = h("button", { class: "button primary wide" }, "Pair");
|
||
|
||
const sync = () => {
|
||
submit.disabled = code.value.trim() === "";
|
||
error.textContent = "";
|
||
};
|
||
|
||
const canPaste = typeof navigator.clipboard?.readText === "function";
|
||
const paste = h("button", { class: "chip", hidden: !canPaste }, ico("paste"), "Paste");
|
||
paste.onclick = async () => {
|
||
try {
|
||
code.value = (await navigator.clipboard.readText()).trim();
|
||
sync();
|
||
} catch {
|
||
paste.hidden = true;
|
||
code.focus();
|
||
}
|
||
};
|
||
|
||
const scan = h("button", { class: "chip", hidden: !canScan }, ico("scan"), "Scan");
|
||
scan.onclick = async () => {
|
||
error.textContent = "";
|
||
try {
|
||
const got = (await scanCode())?.trim();
|
||
if (got == null) return;
|
||
if (!got.startsWith("tty7pair:")) {
|
||
error.textContent = "That QR code isn't a tty7 pairing code.";
|
||
return;
|
||
}
|
||
code.value = got;
|
||
sync();
|
||
// Scanning is the whole gesture: pair straight away.
|
||
submit.click();
|
||
} catch (e) {
|
||
error.textContent =
|
||
e instanceof CameraDenied
|
||
? "tty7 can't use the camera. Allow it in Settings, or paste the code instead."
|
||
: sentence(errorText(e));
|
||
}
|
||
};
|
||
|
||
code.oninput = sync;
|
||
sync();
|
||
|
||
submit.onclick = async () => {
|
||
submit.disabled = true;
|
||
submit.classList.add("busy");
|
||
submit.textContent = "Pairing…";
|
||
try {
|
||
const host = await api.pair(code.value.trim(), name.value.trim() || "phone");
|
||
hostScreen(host, "push");
|
||
} catch (e) {
|
||
error.textContent = errorText(e);
|
||
submit.classList.remove("busy");
|
||
submit.textContent = "Pair";
|
||
submit.disabled = false;
|
||
}
|
||
};
|
||
|
||
return screen({
|
||
title: "Pair a machine",
|
||
back: { label: "Machines", onclick: () => hostsScreen() },
|
||
body: [
|
||
h(
|
||
"ol",
|
||
{ class: "steps" },
|
||
// Named as the desktop's Settings names them: most people pair
|
||
// from there, not from the command line.
|
||
h(
|
||
"li",
|
||
{},
|
||
h(
|
||
"span",
|
||
{},
|
||
"In tty7 on your computer, open ",
|
||
h("strong", {}, "Settings → Mobile"),
|
||
" and turn on ",
|
||
h("strong", {}, "Allow phone access"),
|
||
".",
|
||
),
|
||
),
|
||
h(
|
||
"li",
|
||
{},
|
||
h(
|
||
"span",
|
||
{},
|
||
"Click ",
|
||
h("strong", {}, "Show code"),
|
||
canScan ? ", then scan it here, or copy the code and paste it below." : ", copy the code and paste it below.",
|
||
),
|
||
),
|
||
),
|
||
h(
|
||
"section",
|
||
{ class: "group" },
|
||
h(
|
||
"div",
|
||
{ class: "group-head" },
|
||
h("h2", { class: "group-title" }, "Pairing code"),
|
||
h("div", { class: "chips" }, scan, paste),
|
||
),
|
||
h("div", { class: "card field" }, code),
|
||
error,
|
||
),
|
||
h(
|
||
"section",
|
||
{ class: "group" },
|
||
h("h2", { class: "group-title" }, "Name"),
|
||
h("div", { class: "card field" }, name),
|
||
h(
|
||
"p",
|
||
{ class: "group-note" },
|
||
"How this phone shows up under ",
|
||
h("strong", {}, "Paired phones"),
|
||
" on your computer.",
|
||
),
|
||
),
|
||
],
|
||
footer: h("div", { class: "dock" }, submit),
|
||
});
|
||
});
|
||
}
|
||
|
||
function guessDeviceName() {
|
||
const ua = navigator.userAgent;
|
||
if (/iPhone/.test(ua)) return "iPhone";
|
||
if (/iPad/.test(ua)) return "iPad";
|
||
if (/Android/.test(ua)) return "Android";
|
||
return "tty7 mobile";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// One machine: what needs you first, then every workspace as the desktop
|
||
// groups it.
|
||
|
||
/** Tries again after a dropped connection, sooner at first: 1s, 2s, 4s … up
|
||
* to 15s between tries, for as long as the screen is up. */
|
||
function retrier(run: () => void) {
|
||
let attempt = 0;
|
||
let timer: number | undefined;
|
||
return {
|
||
schedule() {
|
||
clearTimeout(timer);
|
||
timer = window.setTimeout(run, Math.min(15_000, 1000 * 2 ** attempt++));
|
||
},
|
||
/** It worked: the next drop starts from the shortest wait again. */
|
||
reset() {
|
||
attempt = 0;
|
||
clearTimeout(timer);
|
||
},
|
||
cancel() {
|
||
clearTimeout(timer);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** How long a machine may stay silent before the screen says so. */
|
||
const SLOW_CONNECT_MS = 10_000;
|
||
|
||
function hostScreen(host: Host, direction: "push" | "pop" = "pop") {
|
||
go(direction, () => {
|
||
const link = h("p", { class: "link" });
|
||
// Shown here and remembered for the machine list.
|
||
const showLink = (info: LinkInfo | null) => {
|
||
renderLink(link, info);
|
||
seen.set(host.id, { link: info, tabs: seen.get(host.id)?.tabs ?? null });
|
||
};
|
||
// Coming back to a machine whose link was up, it still is: the session
|
||
// outlives the screen, so the last report stands until a new one comes.
|
||
const lastLink = () => seen.get(host.id)?.link ?? { path: "connecting" as const, rtt_ms: 0 };
|
||
showLink(lastLink());
|
||
const notice = h("div", { class: "notice-slot" });
|
||
const body = h("div", { class: "stack" }, skeleton());
|
||
|
||
let alive = true;
|
||
let lastTree: Tree | null = null;
|
||
let slow: number | undefined;
|
||
let query = "";
|
||
// The one workspace on screen, kept per machine across visits.
|
||
let picked = remembered(`workspace.${host.id}`);
|
||
const pick = (key: string) => {
|
||
picked = key;
|
||
remember(`workspace.${host.id}`, key);
|
||
draw();
|
||
};
|
||
const draw = () => {
|
||
if (lastTree) body.replaceChildren(...renderTree(host, lastTree, query, picked, pick));
|
||
};
|
||
const dock = floatingBar("Search tabs", (q) => {
|
||
query = q;
|
||
draw();
|
||
}, { label: "New tab", run: () => lastTree && newTabSheet(host, lastTree, failed, picked) });
|
||
|
||
const menu = menuButton([
|
||
{
|
||
label: "Refresh",
|
||
icon: "refresh",
|
||
run: () => (watching === null ? start() : api.refresh(watching).catch(() => start())),
|
||
},
|
||
{
|
||
label: "Forget this machine",
|
||
icon: "trash",
|
||
danger: true,
|
||
run: async () => {
|
||
if (
|
||
await confirmSheet(
|
||
`Forget ${host.name}?`,
|
||
"You'll need a new pairing code to reach it again.",
|
||
"Forget",
|
||
)
|
||
) {
|
||
await api.forget(host.id);
|
||
hostsScreen();
|
||
}
|
||
},
|
||
},
|
||
]);
|
||
|
||
const view = screen({
|
||
title: host.name,
|
||
back: { label: "Machines", onclick: () => hostsScreen() },
|
||
trailing: [menu],
|
||
subtitle: link,
|
||
body: [notice, body],
|
||
dock,
|
||
});
|
||
|
||
const retry = retrier(() => start());
|
||
let offlineNow = false;
|
||
// The screen's one watch. Starting again ends the last, and only the
|
||
// newest is listened to: a watch that is ending says "closed", which is
|
||
// not this machine going away.
|
||
let watching: number | null = null;
|
||
let generation = 0;
|
||
const unwatch = () => {
|
||
if (watching !== null) api.unwatch(watching).catch(() => {});
|
||
watching = null;
|
||
};
|
||
// The notice on screen is the dropped connection's, for a tree to clear.
|
||
let dropped = false;
|
||
// A network that comes back is the moment to try, not the next tick.
|
||
const online = () => {
|
||
if (offlineNow) start();
|
||
};
|
||
window.addEventListener("online", online);
|
||
|
||
onLeave = () => {
|
||
alive = false;
|
||
generation++;
|
||
unwatch();
|
||
clearTimeout(slow);
|
||
retry.cancel();
|
||
window.removeEventListener("online", online);
|
||
};
|
||
|
||
const failed = (message: string) =>
|
||
notice.replaceChildren(noticeCard({ title: "Couldn't open a tab", body: [sentence(message)] }));
|
||
|
||
const offline = (message: string) => {
|
||
offlineNow = true;
|
||
dropped = true;
|
||
retry.schedule();
|
||
showLink(null);
|
||
notice.replaceChildren(
|
||
noticeCard({
|
||
title: `Can't reach ${host.name}`,
|
||
body: [
|
||
sentence(message),
|
||
` Check that tty7 is running on ${host.name} with phone access on.`,
|
||
],
|
||
actions: [
|
||
{ label: "Try now", run: start },
|
||
{ label: "Pair again", run: () => pairScreen() },
|
||
],
|
||
}),
|
||
);
|
||
if (!lastTree) body.replaceChildren();
|
||
};
|
||
|
||
const start = async () => {
|
||
const mine = ++generation;
|
||
unwatch();
|
||
offlineNow = false;
|
||
retry.cancel();
|
||
clearTimeout(slow);
|
||
// Retrying under a tree, the dropped connection's notice stays up to
|
||
// say why the tree may be stale, until a new one replaces it.
|
||
if (!(lastTree && dropped)) notice.replaceChildren();
|
||
showLink(lastLink());
|
||
if (!lastTree) body.replaceChildren(skeleton());
|
||
// A machine that never answers leaves nothing to show but a spinner;
|
||
// say what is likely wrong while still trying.
|
||
slow = window.setTimeout(() => {
|
||
if (alive && !lastTree)
|
||
notice.replaceChildren(
|
||
noticeCard({
|
||
title: `Still looking for ${host.name}`,
|
||
body: [
|
||
"Check that tty7 is running there with phone access on (",
|
||
h("strong", {}, "Settings → Mobile"),
|
||
"). If the network changed since you paired, pairing again gives this phone its new address.",
|
||
],
|
||
tone: "warn",
|
||
actions: [{ label: "Pair again", run: () => pairScreen() }],
|
||
}),
|
||
);
|
||
}, SLOW_CONNECT_MS);
|
||
try {
|
||
const id = await api.watch(host.id, (msg) => {
|
||
if (!alive || mine !== generation) return;
|
||
switch (msg.type) {
|
||
case "tree":
|
||
clearTimeout(slow);
|
||
retry.reset();
|
||
if (!lastTree || dropped) notice.replaceChildren();
|
||
dropped = false;
|
||
lastTree = msg.tree;
|
||
seen.set(host.id, { link: seen.get(host.id)?.link ?? null, tabs: tabCount(msg.tree) });
|
||
draw();
|
||
break;
|
||
case "link":
|
||
showLink(msg.link);
|
||
break;
|
||
case "error":
|
||
clearTimeout(slow);
|
||
notice.replaceChildren(noticeCard({ title: msg.message, tone: "warn" }));
|
||
if (!lastTree) body.replaceChildren();
|
||
break;
|
||
case "closed":
|
||
offline("The connection closed.");
|
||
break;
|
||
}
|
||
});
|
||
// Left, or started again, while this one was coming up: it is no
|
||
// one's.
|
||
if (!alive || mine !== generation) api.unwatch(id).catch(() => {});
|
||
else watching = id;
|
||
} catch (e) {
|
||
if (alive && mine === generation) {
|
||
clearTimeout(slow);
|
||
offline(errorText(e));
|
||
}
|
||
}
|
||
};
|
||
// Back from the background the stream may be dead, or fine and merely
|
||
// behind: watching again covers both, since the gateway sends the whole
|
||
// tree on every new watch.
|
||
onResume = start;
|
||
start();
|
||
return view;
|
||
});
|
||
}
|
||
|
||
function renderLink(el: HTMLElement, link: LinkInfo | null) {
|
||
el.className = `link ${link?.path ?? "offline"}`;
|
||
const text =
|
||
link === null
|
||
? "Offline"
|
||
: link.path === "connecting"
|
||
? "Connecting…"
|
||
: `${link.path === "direct" ? "Direct" : "Relay"} · ${link.rtt_ms} ms`;
|
||
el.replaceChildren(h("span", { class: "link-dot" }), text);
|
||
}
|
||
|
||
function skeleton() {
|
||
const row = () =>
|
||
h(
|
||
"div",
|
||
{ class: "row skeleton" },
|
||
h("span", { class: "avatar" }),
|
||
h("span", { class: "row-text" }, h("span", { class: "bone" }), h("span", { class: "bone short" })),
|
||
);
|
||
return h("div", { class: "group", ariaHidden: "true" }, h("div", { class: "card" }, row(), row(), row()));
|
||
}
|
||
|
||
const STATUS_WORD: Record<AgentStatus, string> = {
|
||
working: "Working",
|
||
waiting: "Needs input",
|
||
done: "Done",
|
||
idle: "Idle",
|
||
};
|
||
|
||
/** Which machine a pane or workspace is on: a remote the desktop is linked
|
||
* to, or null for the paired machine itself. */
|
||
type Place = { key: string; name: string } | null;
|
||
|
||
function tabCount(tree: Tree) {
|
||
const count = (list: WorkspaceView[]) => list.reduce((n, ws) => n + ws.tabs.length, 0);
|
||
return count(tree.workspaces) + (tree.remotes ?? []).reduce((n, r) => n + count(r.workspaces), 0);
|
||
}
|
||
|
||
/** The panes a search keeps: by tab name, pane title, directory or agent. */
|
||
function matching(ws: WorkspaceView, query: string): WorkspaceView {
|
||
if (!query) return ws;
|
||
const hit = (tab: TabView, pane: PaneView) =>
|
||
[tab.name, pane.title, pane.cwd, pane.agent && agentLook(pane.agent.kind).name, ws.name].some((text) =>
|
||
text?.toLowerCase().includes(query),
|
||
);
|
||
return {
|
||
...ws,
|
||
tabs: ws.tabs
|
||
.map((tab) => ({ ...tab, panes: tab.panes.filter((pane) => hit(tab, pane)) }))
|
||
.filter((tab) => tab.panes.length > 0),
|
||
};
|
||
}
|
||
|
||
/** What names one entry of the workspace switcher: a workspace here, one on
|
||
* a linked machine, or a linked machine with no workspaces to show. */
|
||
function spaceKey(place: Place, ws: WorkspaceView | null) {
|
||
return `${place?.key ?? "local"}:${ws?.id ?? ""}`;
|
||
}
|
||
|
||
function renderTree(
|
||
host: Host,
|
||
tree: Tree,
|
||
query: string,
|
||
picked: string | null,
|
||
pick: (key: string) => void,
|
||
): Node[] {
|
||
if (query) {
|
||
const groups = [
|
||
...tree.workspaces.map((ws) => [null, matching(ws, query)] as const),
|
||
...(tree.remotes ?? []).flatMap((r) => r.workspaces.map((ws) => [r, matching(ws, query)] as const)),
|
||
].filter(([, ws]) => ws.tabs.length > 0);
|
||
return groups.length
|
||
? groups.map(([place, ws]) => workspaceGroup(host, place, ws, place?.name))
|
||
: [h("p", { class: "search-empty" }, `No tab matches “${query}”.`)];
|
||
}
|
||
const remotes = tree.remotes ?? [];
|
||
if (tree.workspaces.length === 0 && remotes.length === 0) {
|
||
return [
|
||
h(
|
||
"div",
|
||
{ class: "empty" },
|
||
h("span", { class: "tile large" }, ico("terminal")),
|
||
h("h2", { class: "empty-title" }, "Nothing open"),
|
||
h("p", { class: "empty-body" }, `Open a tab in tty7 on ${host.name} and it appears here.`),
|
||
),
|
||
];
|
||
}
|
||
|
||
// One workspace at a time, picked from a row above it: this machine's
|
||
// workspaces first, then those of every machine it reaches over SSH. A
|
||
// linked machine with none to show gets one entry for its state.
|
||
type Space = { key: string; label: string; count: number | null; remote: RemoteView | null; ws: WorkspaceView | null };
|
||
const spaces: Space[] = [
|
||
...tree.workspaces.map((ws) => ({
|
||
key: spaceKey(null, ws),
|
||
label: workspaceName(ws.name),
|
||
count: ws.tabs.length,
|
||
remote: null,
|
||
ws,
|
||
})),
|
||
...remotes.flatMap((r): Space[] => {
|
||
const ready = r.connected && !r.error && !r.pending;
|
||
return ready && r.workspaces.length
|
||
? r.workspaces.map((ws) => ({
|
||
key: spaceKey(r, ws),
|
||
label: `${r.name} · ${workspaceName(ws.name)}`,
|
||
count: ws.tabs.length,
|
||
remote: r,
|
||
ws,
|
||
}))
|
||
: [{ key: spaceKey(r, null), label: r.name, count: null, remote: r, ws: null }];
|
||
}),
|
||
];
|
||
const on = spaces.find((sp) => sp.key === picked) ?? spaces[0];
|
||
|
||
const out: Node[] = [];
|
||
if (spaces.length > 1) {
|
||
const bar = h(
|
||
"div",
|
||
{ class: "ws-bar", role: "tablist", ariaLabel: "Workspaces" },
|
||
...spaces.map((sp) => {
|
||
const chip = h(
|
||
"button",
|
||
{ class: sp === on ? "ws-chip on" : "ws-chip", role: "tab", onclick: () => pick(sp.key) },
|
||
sp.remote && ico("server"),
|
||
h("span", { class: "ws-chip-name" }, sp.label),
|
||
sp.count !== null && h("span", { class: "ws-chip-count" }, String(sp.count)),
|
||
);
|
||
chip.setAttribute("aria-selected", String(sp === on));
|
||
return chip;
|
||
}),
|
||
);
|
||
out.push(bar);
|
||
// The picked entry in view, when it is far along the row.
|
||
requestAnimationFrame(() =>
|
||
bar.querySelector<HTMLElement>(".ws-chip.on")?.scrollIntoView({ block: "nearest", inline: "nearest" }),
|
||
);
|
||
}
|
||
|
||
const remote = on.remote;
|
||
if (remote) out.push(remoteState(host, remote));
|
||
// The switcher already names it and counts its tabs.
|
||
if (on.ws) out.push(workspaceGroup(host, remote, on.ws, undefined, spaces.length === 1));
|
||
return out;
|
||
}
|
||
|
||
/** A linked machine's heading: its name and the state of the link to it. */
|
||
function remoteState(host: Host, remote: RemoteView) {
|
||
const state = !remote.connected
|
||
? "Link down"
|
||
: remote.error
|
||
? "Not answering"
|
||
: remote.pending
|
||
? "Reading…"
|
||
: "Connected";
|
||
const tone = !remote.connected || remote.error ? "offline" : remote.pending ? "connecting" : "direct";
|
||
return h(
|
||
"section",
|
||
{ class: "remote" },
|
||
h(
|
||
"div",
|
||
{ class: "remote-head" },
|
||
h("span", { class: "tile" }, ico("server")),
|
||
h(
|
||
"div",
|
||
{ class: "remote-titles" },
|
||
h("h2", { class: "remote-name" }, remote.name),
|
||
h("p", { class: `link ${tone}` }, h("span", { class: "link-dot" }), `SSH · ${state}`),
|
||
),
|
||
),
|
||
!remote.connected &&
|
||
h(
|
||
"p",
|
||
{ class: "remote-note" },
|
||
`tty7 on ${host.name} lost its link to ${remote.name}. Reconnect it there to reach its workspaces.`,
|
||
),
|
||
remote.error && h("p", { class: "remote-note" }, sentence(remote.error)),
|
||
remote.pending && skeleton(),
|
||
remote.connected &&
|
||
!remote.error &&
|
||
!remote.pending &&
|
||
remote.workspaces.length === 0 &&
|
||
h("p", { class: "remote-note" }, `No workspaces on ${remote.name}.`),
|
||
);
|
||
}
|
||
|
||
/** A workspace is one card of its tabs, named as the desktop's sidebar names
|
||
* them; a split tab gives each of its panes a row. `where` names the machine
|
||
* when a search mixes them. */
|
||
function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, where?: string, headed = true) {
|
||
const rows = ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane)));
|
||
return h(
|
||
"section",
|
||
{ class: "group" },
|
||
headed &&
|
||
h(
|
||
"div",
|
||
{ class: "group-head" },
|
||
h("h2", { class: "group-title" }, where ? `${where} · ${workspaceName(ws.name)}` : workspaceName(ws.name)),
|
||
h("span", { class: "group-count" }, String(ws.tabs.length)),
|
||
),
|
||
rows.length ? h("div", { class: "card" }, ...rows) : h("p", { class: "group-empty" }, "No tabs open."),
|
||
);
|
||
}
|
||
|
||
/** The agents a new tab can start in, and the command that starts each. */
|
||
const STARTERS = [
|
||
{ kind: "claude", command: "claude" },
|
||
{ kind: "codex", command: "codex" },
|
||
{ kind: null, command: null },
|
||
] as const;
|
||
|
||
function remembered(key: string) {
|
||
try {
|
||
return localStorage.getItem(key);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function remember(key: string, value: string) {
|
||
try {
|
||
localStorage.setItem(key, value);
|
||
} catch {
|
||
// Private storage off: the choice is just not kept.
|
||
}
|
||
}
|
||
|
||
/** The sheet "+" opens on a machine: pick what runs, pick the workspace, Open.
|
||
* The tab starts in the directory its workspace's last tab is in, sized to
|
||
* this screen, and the agent's command is typed into it once it is live. */
|
||
function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void, picked: string | null) {
|
||
type Target = { place: Place; ws: WorkspaceView };
|
||
const targets: Target[] = [
|
||
...tree.workspaces.map((ws) => ({ place: null, ws })),
|
||
...(tree.remotes ?? [])
|
||
.filter((r) => r.connected && !r.error && !r.pending)
|
||
.flatMap((r) => r.workspaces.map((ws) => ({ place: { key: r.key, name: r.name }, ws }))),
|
||
];
|
||
let starter = Math.max(0, STARTERS.findIndex((s) => (s.kind ?? "shell") === remembered("newtab.agent")));
|
||
// The workspace on screen, unless it cannot take a new tab.
|
||
let target = Math.max(0, targets.findIndex((t) => spaceKey(t.place, t.ws) === picked));
|
||
|
||
const agents = h("div", { class: "agent-grid" });
|
||
const places = h("div", { class: "card" });
|
||
const open = h("button", { class: "button primary wide sheet-open" }, "Open");
|
||
const error = h("p", { class: "field-error", role: "alert" });
|
||
|
||
const draw = () => {
|
||
agents.replaceChildren(
|
||
...STARTERS.map((s, i) =>
|
||
h(
|
||
"button",
|
||
{ class: i === starter ? "agent-choice on" : "agent-choice", onclick: () => ((starter = i), draw()) },
|
||
avatar(s.kind ? { kind: s.kind, status: "idle" } : null),
|
||
h("span", {}, s.kind ? agentLook(s.kind).name : "Shell"),
|
||
),
|
||
),
|
||
);
|
||
places.replaceChildren(
|
||
...targets.map((t, i) =>
|
||
h(
|
||
"button",
|
||
{ class: "row choice", onclick: () => ((target = i), draw()) },
|
||
h("span", { class: "row-title" }, workspaceName(t.ws.name)),
|
||
h("span", { class: "row-meta" }, t.place?.name ?? `${t.ws.tabs.length} ${t.ws.tabs.length === 1 ? "tab" : "tabs"}`),
|
||
i === target ? ico("check", "icon choice-check") : h("span", { class: "choice-check" }),
|
||
),
|
||
),
|
||
);
|
||
open.disabled = targets.length === 0;
|
||
};
|
||
draw();
|
||
|
||
const { remove } = openSheet(
|
||
"New tab",
|
||
h("div", { class: "sheet-body" },
|
||
h("section", { class: "sheet-group" }, h("h3", { class: "group-title" }, "Agent"), agents),
|
||
h(
|
||
"section",
|
||
{ class: "sheet-group" },
|
||
h("h3", { class: "group-title" }, "Workspace"),
|
||
targets.length ? places : h("p", { class: "group-empty" }, `Open a workspace in tty7 on ${host.name} first.`),
|
||
),
|
||
error,
|
||
),
|
||
open,
|
||
);
|
||
open.onclick = async () => {
|
||
const { place, ws } = targets[target];
|
||
const s = STARTERS[starter];
|
||
remember("newtab.agent", s.kind ?? "shell");
|
||
open.disabled = true;
|
||
error.textContent = "";
|
||
const cwd = ws.tabs.at(-1)?.panes[0]?.cwd ?? null;
|
||
try {
|
||
const created = await api.tabNew(host.id, place?.key ?? null, ws.id, cwd, phoneGrid());
|
||
remove();
|
||
const title = s.kind ? agentLook(s.kind).name : "shell";
|
||
terminalScreen(host, place, { id: created.pane_id, title, cwd }, title, s.command ?? undefined);
|
||
} catch (e) {
|
||
error.textContent = sentence(errorText(e));
|
||
failed(errorText(e));
|
||
open.disabled = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
/** A sheet risen over the dimmed screen, with a title and a way to close. */
|
||
function openSheet(title: string, ...content: Child[]) {
|
||
const sheet = h(
|
||
"div",
|
||
{ class: "sheet", role: "dialog", ariaLabel: title },
|
||
h("span", { class: "sheet-grip" }),
|
||
h(
|
||
"div",
|
||
{ class: "sheet-head" },
|
||
h("h2", { class: "sheet-title" }, title),
|
||
h("button", { class: "round", ariaLabel: "Close", onclick: () => close() }, ico("close")),
|
||
),
|
||
...content,
|
||
);
|
||
const scrim = h("div", { class: "scrim", onclick: (e: Event) => e.target === scrim && close() }, sheet);
|
||
const close = () => {
|
||
scrim.classList.add("leaving");
|
||
setTimeout(() => scrim.remove(), still.matches ? 0 : 220);
|
||
};
|
||
document.body.append(scrim);
|
||
return { close, remove: () => scrim.remove() };
|
||
}
|
||
|
||
/** What has changed in the repository a pane is in: a block per file, its
|
||
* lines coloured, then the files git does not track yet. */
|
||
function changesSheet(host: Host, place: Place, cwd: string) {
|
||
const body = h("div", { class: "sheet-body diff-body" }, skeleton());
|
||
openSheet("Changes", body);
|
||
api.diff(host.id, place?.key ?? null, cwd).then(
|
||
(d) => body.replaceChildren(...renderDiff(d)),
|
||
(e) => body.replaceChildren(h("p", { class: "group-empty" }, sentence(errorText(e)))),
|
||
);
|
||
}
|
||
|
||
function renderDiff(d: api.Diff): Node[] {
|
||
const files = d.patch.split(/^(?=diff --git )/m).filter((f) => f.startsWith("diff --git "));
|
||
const out: Node[] = [
|
||
h("p", { class: "diff-root" }, shortPath(d.root), files.length || d.untracked.length ? "" : " · no changes"),
|
||
];
|
||
for (const f of files) {
|
||
const lines = f.split("\n");
|
||
const name =
|
||
/^\+\+\+ b\/(.*)$/m.exec(f)?.[1] ?? /^--- a\/(.*)$/m.exec(f)?.[1] ?? /^diff --git a\/(.*) b\//.exec(f)?.[1] ?? "?";
|
||
let add = 0;
|
||
let del = 0;
|
||
const code = h("div", { class: "diff-code" });
|
||
let inHunk = false;
|
||
for (const line of lines) {
|
||
if (line.startsWith("@@")) inHunk = true;
|
||
if (!inHunk) continue;
|
||
const kind = line.startsWith("@@") ? "hunk" : line[0] === "+" ? "add" : line[0] === "-" ? "del" : "ctx";
|
||
if (kind === "add") add++;
|
||
if (kind === "del") del++;
|
||
code.append(h("div", { class: `diff-line ${kind}` }, line || " "));
|
||
}
|
||
const binary = !inHunk && /^Binary files/m.test(f);
|
||
const block = h(
|
||
"details",
|
||
{ class: "diff-file", open: files.length <= 6 },
|
||
h(
|
||
"summary",
|
||
{},
|
||
h("span", { class: "diff-name" }, name),
|
||
h("span", { class: "diff-add" }, `+${add}`),
|
||
h("span", { class: "diff-del" }, `−${del}`),
|
||
),
|
||
binary ? h("p", { class: "group-empty" }, "A binary file.") : code,
|
||
);
|
||
out.push(block);
|
||
}
|
||
if (d.truncated) out.push(h("p", { class: "group-empty" }, "Cut short: the rest is too long to show here."));
|
||
if (d.untracked.length) {
|
||
out.push(
|
||
section(
|
||
`Not tracked yet · ${d.untracked.length}`,
|
||
...d.untracked.slice(0, 200).map((p) => h("div", { class: "row choice static" }, h("span", { class: "row-title diff-name" }, p))),
|
||
),
|
||
);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Asks before something that cannot be undone. `window.confirm` is no use:
|
||
* the iOS WebView shows nothing for it and answers "no" at once. */
|
||
function confirmSheet(title: string, text: string, action: string): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
let answered = false;
|
||
const answer = (yes: boolean) => {
|
||
if (answered) return;
|
||
answered = true;
|
||
sheet.close();
|
||
resolve(yes);
|
||
};
|
||
const sheet = openSheet(
|
||
title,
|
||
h(
|
||
"div",
|
||
{ class: "sheet-body" },
|
||
h("p", { class: "sheet-text" }, text),
|
||
h("button", { class: "button danger wide", onclick: () => answer(true) }, action),
|
||
h("button", { class: "button tinted wide", onclick: () => answer(false) }, "Cancel"),
|
||
),
|
||
);
|
||
// Closed with its × or by tapping outside it: a no.
|
||
const scrim = document.body.lastElementChild!;
|
||
new MutationObserver((_, obs) => {
|
||
if (!scrim.isConnected || scrim.classList.contains("leaving")) {
|
||
obs.disconnect();
|
||
answer(false);
|
||
}
|
||
}).observe(scrim, { attributes: true, attributeFilter: ["class"] });
|
||
});
|
||
}
|
||
|
||
/** The grid that fills this screen at the readable size: what a tab started
|
||
* here is spawned at, since no desktop window is showing it yet. */
|
||
function phoneGrid() {
|
||
const cellW = readablePx() * CELL_EM;
|
||
const cellH = readablePx() * 1.18;
|
||
// The terminal screen's bar, its dock (keys, page dots, message box, the
|
||
// home indicator's gap) and the xterm padding.
|
||
const chrome = 56 + 132 + 16;
|
||
return {
|
||
cols: Math.max(20, Math.floor((window.innerWidth - 12) / cellW)),
|
||
rows: Math.max(5, Math.floor((window.innerHeight - chrome) / cellH)),
|
||
};
|
||
}
|
||
|
||
/** The desktop leaves a workspace unnamed as "-". */
|
||
function workspaceName(name: string) {
|
||
return name && name !== "-" ? name : "Untitled workspace";
|
||
}
|
||
|
||
/** A pane's row: its tab's name first, as on the desktop, then what the pane
|
||
* is doing — its agent's state, or where its shell is. */
|
||
function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView) {
|
||
const agent = pane.agent;
|
||
const sub: Child[] = [];
|
||
if (agent && agent.status !== "idle")
|
||
sub.push(h("span", { class: `status-word ${agent.status}` }, STATUS_WORD[agent.status]));
|
||
// A tab named after its directory says the cwd already; what tells its
|
||
// panes apart then is what runs in them.
|
||
const namedByPath = /^[~/]/.test(tab.name);
|
||
const split = tab.panes.length > 1 || namedByPath ? pane.title : null;
|
||
const dir = agent || namedByPath ? null : shortPath(pane.cwd);
|
||
const detail = [split, agent?.message ?? dir].filter(Boolean).join(" · ");
|
||
if (detail) sub.push(sub.length ? ` · ${detail}` : detail);
|
||
return h(
|
||
"button",
|
||
{
|
||
class: tab.hibernated ? "row asleep" : "row",
|
||
onclick: () => terminalScreen(host, place, pane, tab.name),
|
||
},
|
||
avatar(agent),
|
||
h(
|
||
"span",
|
||
{ class: "row-text" },
|
||
h(
|
||
"span",
|
||
{ class: "row-title" },
|
||
tab.name,
|
||
tab.hibernated && h("span", { class: "tag" }, "Asleep"),
|
||
),
|
||
sub.length > 0 && h("span", { class: "row-sub" }, ...sub),
|
||
),
|
||
agent && agent.status !== "idle" && agent.status !== "done" && h("span", { class: `status-dot ${agent.status}` }),
|
||
ico("chevron", "icon row-chevron"),
|
||
);
|
||
}
|
||
|
||
/** A pane's avatar, as the desktop's tab strip draws it: the agent's mark on
|
||
* its brand colour, or a terminal, with the status dot as a badge. Waiting is
|
||
* hollow, so it differs from Done in shape and not only in hue. */
|
||
function avatar(agent: AgentView | null | undefined, cls = "avatar") {
|
||
const el = h("span", { class: cls });
|
||
if (agent) {
|
||
const look = agentLook(agent.kind);
|
||
el.title = `${look.name}: ${STATUS_WORD[agent.status]}`;
|
||
// The agent's own colours, the disc the desktop's sidebar draws.
|
||
el.classList.add("branded");
|
||
el.style.setProperty("--brand-field", look.field);
|
||
el.style.setProperty("--brand-ink", look.ink);
|
||
if (look.mark) {
|
||
const mark = h("span", { class: "mark" });
|
||
mark.style.setProperty("--mark", `url("${look.mark}")`);
|
||
el.append(mark);
|
||
} else el.append(h("span", { class: "glyph" }, look.name.slice(0, 2)));
|
||
} else el.append(h("span", { class: "glyph" }, ">_"));
|
||
return el;
|
||
}
|
||
|
||
/** The last two segments of a path: the part that tells panes apart. */
|
||
function shortPath(path: string | null | undefined) {
|
||
if (!path) return "";
|
||
const parts = path.split("/").filter(Boolean);
|
||
return parts.length <= 2 ? path : `…/${parts.slice(-2).join("/")}`;
|
||
}
|
||
|
||
interface NoticeParts {
|
||
title: string;
|
||
body?: Child[];
|
||
tone?: "warn" | "bad";
|
||
actions?: { label: string; run: () => void }[];
|
||
}
|
||
|
||
function noticeCard({ title, body, tone = "bad", actions = [] }: NoticeParts) {
|
||
return h(
|
||
"div",
|
||
{ class: `notice ${tone}`, role: "status" },
|
||
ico("alert", "icon notice-icon"),
|
||
h(
|
||
"div",
|
||
{ class: "notice-text" },
|
||
h("p", { class: "notice-title" }, title),
|
||
body && h("p", { class: "notice-body" }, ...body),
|
||
actions.length > 0 &&
|
||
h(
|
||
"div",
|
||
{ class: "notice-actions" },
|
||
...actions.map((a) => h("button", { class: "button tinted small", onclick: a.run }, a.label)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
interface MenuItem {
|
||
label: string;
|
||
icon: keyof typeof icon;
|
||
danger?: boolean;
|
||
run: () => void;
|
||
}
|
||
|
||
/** A trailing ⋯ button with a small menu that drops from it. Items can be
|
||
* given as a function, to be read afresh each time it opens. */
|
||
function menuButton(items: MenuItem[] | (() => MenuItem[]), cls = "nav-icon") {
|
||
const wrap = h("div", { class: "menu-wrap" });
|
||
const list = h("div", { class: "menu", role: "menu", hidden: true });
|
||
const outside = (e: Event) => {
|
||
if (!wrap.contains(e.target as Node)) close();
|
||
};
|
||
const close = () => {
|
||
list.hidden = true;
|
||
document.removeEventListener("pointerdown", outside, true);
|
||
};
|
||
const fill = () => {
|
||
list.replaceChildren();
|
||
for (const item of typeof items === "function" ? items() : items)
|
||
list.append(
|
||
h(
|
||
"button",
|
||
{
|
||
class: item.danger ? "menu-item danger" : "menu-item",
|
||
role: "menuitem",
|
||
onclick: () => {
|
||
close();
|
||
item.run();
|
||
},
|
||
},
|
||
h("span", {}, item.label),
|
||
ico(item.icon),
|
||
),
|
||
);
|
||
};
|
||
const button = h("button", { class: cls, ariaLabel: "More" }, ico("more"));
|
||
button.onclick = () => {
|
||
if (!list.hidden) return close();
|
||
fill();
|
||
list.hidden = false;
|
||
document.addEventListener("pointerdown", outside, true);
|
||
};
|
||
wrap.append(button, list);
|
||
return wrap;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// A terminal. The pane keeps the size its desktop window gave it — the phone
|
||
// watches rather than attaches — so the font shrinks to fit the width.
|
||
|
||
// The desktop's Light and Dark ANSI palettes (src/ui/presets.rs), so a pane
|
||
// reads the same on the phone as in the window it lives in, on the app's own
|
||
// neutral surface.
|
||
const ANSI = {
|
||
light: ["#24292e", "#d1242f", "#1a7f37", "#9a6700", "#0969da", "#8250df", "#1b7c83", "#6e7781", "#57606a", "#cf222e", "#1f883d", "#bf8700", "#218bff", "#a475f9", "#3192aa", "#8c959f"],
|
||
dark: ["#616161", "#ff8272", "#b4fa72", "#fefdc2", "#a5d5fe", "#ff8ffd", "#d0d1fe", "#f1f1f1", "#8e8e8e", "#ffc4bd", "#d6fcb9", "#fefdd5", "#c1e3fe", "#ffb1fe", "#e5e6fe", "#feffff"],
|
||
};
|
||
const NAMES = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] as const;
|
||
|
||
function terminalTheme(): ITheme {
|
||
const dark = isDark();
|
||
const ansi = dark ? ANSI.dark : ANSI.light;
|
||
const theme: Record<string, string> = dark
|
||
? { background: "#1e1e20", foreground: "#ececed", cursor: "#ececed", cursorAccent: "#1e1e20", selectionBackground: "#ffffff2e" }
|
||
: { background: "#fcfcfb", foreground: "#1c1c1e", cursor: "#1c1c1e", cursorAccent: "#fcfcfb", selectionBackground: "#0000001f" };
|
||
// xterm's scrollbar, drawn to match the system indicator WebKit gives the
|
||
// pan (style.css shapes it).
|
||
const bar = dark ? "#ffffff" : "#000000";
|
||
theme.scrollbarSliderBackground = `${bar}59`;
|
||
theme.scrollbarSliderHoverBackground = `${bar}59`;
|
||
theme.scrollbarSliderActiveBackground = `${bar}73`;
|
||
NAMES.forEach((n, i) => {
|
||
theme[n] = ansi[i];
|
||
theme[`bright${n[0].toUpperCase()}${n.slice(1)}`] = ansi[i + 8];
|
||
});
|
||
return theme as ITheme;
|
||
}
|
||
|
||
/** A key on the bar: a sequence to send, the ctrl latch, or the clipboard.
|
||
* `text` is what it shows when that is not its label; `flex` its share of
|
||
* the row. */
|
||
type Key = {
|
||
label: string;
|
||
seq: string;
|
||
text?: string;
|
||
icon?: keyof typeof icon;
|
||
latch?: boolean;
|
||
paste?: boolean;
|
||
flex?: number;
|
||
};
|
||
|
||
/** The key row, a page at a time: what an agent and a shell need most first,
|
||
* a swipe away the rest. Settings → Key bar changes it. */
|
||
const KEY_PAGES: Key[][] = [
|
||
[
|
||
{ label: "esc", seq: "\x1b", flex: 1.2 },
|
||
{ label: "tab", seq: "\t", flex: 1.2 },
|
||
// Claude Code's mode switch, among others.
|
||
{ label: "Shift Tab", text: "⇧tab", seq: "\x1b[Z", flex: 1.4 },
|
||
{ label: "ctrl", seq: "", latch: true, flex: 1.3 },
|
||
{ label: "^C", seq: "\x03" },
|
||
{ label: "Up", seq: "\x1b[A", icon: "up", flex: 0.9 },
|
||
{ label: "Down", seq: "\x1b[B", icon: "down", flex: 0.9 },
|
||
// Enter on its own, without opening the keyboard: confirming an agent's
|
||
// highlighted choice, or a prompt's default.
|
||
{ label: "Enter", seq: "\r", icon: "enter" },
|
||
],
|
||
[
|
||
{ label: "Left", seq: "\x1b[D", icon: "left" },
|
||
{ label: "Right", seq: "\x1b[C", icon: "right" },
|
||
// Quick answers: an agent's numbered choices (Claude Code takes the digit
|
||
// alone) and y/n prompts. Each is just the keystroke; ⏎ is there for the
|
||
// prompts that also want Enter.
|
||
{ label: "1", seq: "1" },
|
||
{ label: "2", seq: "2" },
|
||
{ label: "3", seq: "3" },
|
||
{ label: "y", seq: "y" },
|
||
{ label: "n", seq: "n" },
|
||
{ label: "Paste", seq: "", icon: "paste", paste: true, flex: 1.2 },
|
||
],
|
||
[
|
||
{ label: "|", seq: "|" },
|
||
{ label: "/", seq: "/" },
|
||
{ label: "~", seq: "~" },
|
||
{ label: "-", seq: "-" },
|
||
{ label: "_", seq: "_" },
|
||
// The shell's own history search.
|
||
{ label: "Search history", text: "^R", seq: "\x12", flex: 1.2 },
|
||
{ label: "Home", seq: "\x1b[H", flex: 1.4 },
|
||
{ label: "End", seq: "\x1b[F", flex: 1.4 },
|
||
],
|
||
];
|
||
|
||
/** A page holds this many keys before they get too narrow to hit. */
|
||
const KEYS_PER_PAGE = 8;
|
||
const MAX_KEY_PAGES = 6;
|
||
|
||
/** The key row as this phone has it: its own, or the default. */
|
||
function keyPages(): Key[][] {
|
||
try {
|
||
const saved = JSON.parse(remembered("keys") ?? "null");
|
||
if (
|
||
Array.isArray(saved) &&
|
||
saved.length &&
|
||
saved.every(
|
||
(page) =>
|
||
Array.isArray(page) &&
|
||
page.every((k) => k && typeof k.label === "string" && typeof k.seq === "string"),
|
||
)
|
||
)
|
||
return saved;
|
||
} catch {
|
||
// Unreadable: the default stands.
|
||
}
|
||
return KEY_PAGES;
|
||
}
|
||
|
||
function saveKeyPages(pages: Key[][] | null) {
|
||
remember("keys", pages ? JSON.stringify(pages.filter((p) => p.length)) : "null");
|
||
}
|
||
|
||
/** Keys to add from, beyond the default row: the rest of what a shell or an
|
||
* agent is driven with. */
|
||
const KEY_CATALOG: Key[] = [
|
||
...KEY_PAGES.flat(),
|
||
{ label: "ctrl D", text: "^D", seq: "\x04" },
|
||
{ label: "ctrl Z", text: "^Z", seq: "\x1a" },
|
||
{ label: "ctrl L", text: "^L", seq: "\x0c" },
|
||
{ label: "ctrl A", text: "^A", seq: "\x01" },
|
||
{ label: "ctrl E", text: "^E", seq: "\x05" },
|
||
{ label: "ctrl U", text: "^U", seq: "\x15" },
|
||
{ label: "ctrl W", text: "^W", seq: "\x17" },
|
||
{ label: "Page Up", text: "PgUp", seq: "\x1b[5~", flex: 1.3 },
|
||
{ label: "Page Down", text: "PgDn", seq: "\x1b[6~", flex: 1.3 },
|
||
{ label: "Delete", text: "del", seq: "\x1b[3~", flex: 1.2 },
|
||
{ label: "Backspace", text: "⌫", seq: "\x7f" },
|
||
{ label: "Space", text: "␣", seq: " " },
|
||
...[":", ";", "@", "#", "$", "*", "&", "=", "'", '"', "`", "\\", "<", ">", "[", "]", "{", "}", "(", ")"].map(
|
||
(c) => ({ label: c, seq: c }),
|
||
),
|
||
].filter((k, i, all) => all.findIndex((o) => o.label === k.label) === i);
|
||
|
||
/** What a custom key's "sends" field means: text as written, with `^X` for
|
||
* ctrl-X and `\e \r \n \t \\` for escape, Enter, newline, tab and a
|
||
* backslash. */
|
||
function keySequence(written: string) {
|
||
return written.replace(/\^([A-Za-z@\[\]\\^_?])|\\([ernt\\])/g, (_, ctrl?: string, esc?: string) => {
|
||
if (ctrl) return ctrl === "?" ? "\x7f" : String.fromCharCode(ctrl.toUpperCase().charCodeAt(0) & 0x1f);
|
||
return { e: "\x1b", r: "\r", n: "\n", t: "\t", "\\": "\\" }[esc as "e"];
|
||
});
|
||
}
|
||
|
||
/** A key as the bar shows it. */
|
||
function keyFace(k: Key) {
|
||
return k.icon ? ico(k.icon) : (k.text ?? k.label);
|
||
}
|
||
|
||
/** Hack's advance width, in ems — what the fit divides by. */
|
||
const CELL_EM = 0.602;
|
||
/** The smallest font a pane is read at before it pans instead of shrinking:
|
||
* the text size chosen in Settings. */
|
||
const readablePx = () => prefs.textSize;
|
||
|
||
/** What was typed into a pane's compose box and not sent yet, by pane. Kept
|
||
* across leaving the pane, and across a send that did not get through. */
|
||
const drafts = new Map<string, string>();
|
||
|
||
/** `run` is typed into the pane, then Enter, once it is first live: the
|
||
* agent a new tab was opened for. */
|
||
function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, run?: string) {
|
||
go("push", () => {
|
||
// What the pane is doing and whether keystrokes will land, in words: the
|
||
// one line under the title.
|
||
const stateWord = h("span", {}, "Connecting…");
|
||
const state = h("span", { class: "term-state connecting" }, h("span", { class: "link-dot" }), stateWord);
|
||
// On a remote, the path says which machine it is on, the way a prompt does.
|
||
const where = (path: string | null | undefined) =>
|
||
place && path ? `${place.name}:${shortPath(path)}` : shortPath(path);
|
||
const cwd = h("span", { class: "term-cwd" }, where(pane.cwd));
|
||
let paneCwd = pane.cwd ?? null;
|
||
const sub = h("span", { class: "term-sub" }, state, cwd);
|
||
// Copying, the fit and the phone's size live in the ⋯ menu; the bar keeps
|
||
// only the way back and what this is.
|
||
const menu = menuButton(
|
||
() => [
|
||
{ label: "Select text", icon: "copy", run: () => selecting(copyView.hidden) },
|
||
{ label: "Find", icon: "search", run: () => finding(true) },
|
||
...(paneCwd ? [{ label: "Changes", icon: "compose" as const, run: () => changesSheet(host, place, paneCwd!) }] : []),
|
||
...(cramped && !leased
|
||
? [
|
||
{
|
||
label: readable ? "Fit the whole width" : "Make the text readable",
|
||
icon: readable ? ("fit" as const) : ("zoom" as const),
|
||
run: toggleZoom,
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
label: wanted ? "Give back the desktop's size" : "Use this phone's size",
|
||
icon: "phone" as const,
|
||
run: toggleTake,
|
||
},
|
||
],
|
||
"round",
|
||
);
|
||
const back = () => hostScreen(host);
|
||
onBack = back;
|
||
const bar = h(
|
||
"header",
|
||
{ class: "term-nav" },
|
||
h("button", { class: "round", ariaLabel: `Back to ${host.name}`, onclick: back }, ico("back")),
|
||
h("div", { class: "term-titles" }, h("span", { class: "term-title" }, title), sub),
|
||
menu,
|
||
);
|
||
const screenEl = h("div", { class: "term" });
|
||
const banner = h("div", { class: "term-banner-slot" });
|
||
// Selecting in xterm itself does not work by touch. The pane's text is
|
||
// laid out here instead, as a plain page the phone selects in its own
|
||
// way — handles, magnifier, Copy.
|
||
const copyText = h("pre", { class: "term-copy-text" });
|
||
const copyDone = h("button", { class: "button tinted small" }, "Done");
|
||
const copyView = h(
|
||
"div",
|
||
{ class: "term-copy", hidden: true },
|
||
h("div", { class: "term-copy-bar" }, h("span", {}, "Select text to copy"), copyDone),
|
||
copyText,
|
||
);
|
||
// Find in the pane's scrollback: the match is selected and scrolled to.
|
||
const findInput = h("input", {
|
||
type: "search",
|
||
class: "search-input",
|
||
placeholder: "Find in this pane",
|
||
enterKeyHint: "search",
|
||
autocapitalize: "off",
|
||
spellcheck: false,
|
||
ariaLabel: "Find in this pane",
|
||
});
|
||
findInput.setAttribute("autocorrect", "off");
|
||
const findCount = h("span", { class: "find-count" });
|
||
const findPrev = h("button", { class: "nav-icon", ariaLabel: "Previous match" }, ico("up"));
|
||
const findNext = h("button", { class: "nav-icon", ariaLabel: "Next match" }, ico("down"));
|
||
const findDone = h("button", { class: "button tinted small" }, "Done");
|
||
const findBar = h(
|
||
"div",
|
||
{ class: "find-bar", hidden: true },
|
||
h("label", { class: "search" }, ico("search"), findInput, findCount),
|
||
findPrev,
|
||
findNext,
|
||
findDone,
|
||
);
|
||
const pages = h("div", { class: "key-pages" });
|
||
const dots = h("div", { class: "key-dots", ariaHidden: "true" });
|
||
|
||
// The message box: a real text field, so the phone's own keyboard works
|
||
// in full — autocorrect, dictation, an IME's candidates, moving the
|
||
// caret — and what is written goes in whole, then Enter.
|
||
const draftKey = `${host.id}/${place?.key ?? ""}/${pane.id}`;
|
||
const field = h("textarea", {
|
||
class: "compose-input",
|
||
rows: 1,
|
||
value: drafts.get(draftKey) ?? "",
|
||
placeholder: pane.agent ? `Message ${agentLook(pane.agent.kind).name}…` : "Type a command…",
|
||
enterKeyHint: "send",
|
||
ariaLabel: "Message",
|
||
});
|
||
const sendKey = h("button", { class: "round send", ariaLabel: "Send" }, ico("send"));
|
||
// Typing straight into the terminal, key by key, for what a message box
|
||
// cannot do: a full-screen program, a password prompt.
|
||
const keyboard = h("button", { class: "round", ariaLabel: "Type into the terminal" }, ico("keyboard"));
|
||
// Past messages: the whole list, searchable, while the box is empty; the
|
||
// ones that match, in place of the key row, as it is written in.
|
||
const historyKey = h("button", { class: "round", ariaLabel: "History" }, ico("history"));
|
||
// A photo or a file, put on the machine; its path goes into the box, for
|
||
// an agent to be pointed at with whatever is written around it.
|
||
const attachKey = h("button", { class: "round", ariaLabel: "Attach a photo or file" }, ico("attach"));
|
||
const picker = h("input", { type: "file", multiple: true, hidden: true });
|
||
const compose = h("div", { class: "compose" }, attachKey, historyKey, field, sendKey, keyboard, picker);
|
||
const suggest = h("div", { class: "suggest", hidden: true });
|
||
// An agent's numbered choices — a permission to grant, a question — as
|
||
// buttons over the key row while it waits for an answer.
|
||
const answers = h("div", { class: "answers", hidden: true, role: "group", ariaLabel: "Answers" });
|
||
let agentWaiting = pane.agent?.status === "waiting";
|
||
// What typing straight into the terminal goes through. xterm's own hidden
|
||
// textarea is not: iOS input methods never commit into it (a pinyin
|
||
// candidate stays unwritten), so this one, a plain field the keyboard
|
||
// treats like any other, takes the keys and hands them on as they are
|
||
// committed. Nothing is sent mid-composition.
|
||
const typing = h("textarea", {
|
||
class: "term-typing",
|
||
rows: 1,
|
||
autocapitalize: "off",
|
||
spellcheck: false,
|
||
ariaLabel: "Type into the terminal",
|
||
});
|
||
typing.setAttribute("autocorrect", "off");
|
||
typing.setAttribute("autocomplete", "off");
|
||
|
||
const view = h(
|
||
"div",
|
||
{ class: "screen term-screen" },
|
||
bar,
|
||
h("div", { class: "term-wrap" }, screenEl, typing, copyView, findBar, banner),
|
||
h("div", { class: "term-dock" }, h("div", { class: "key-slot" }, pages, answers, suggest), dots, compose),
|
||
);
|
||
|
||
const term = new Terminal({
|
||
cols: 80,
|
||
rows: 24,
|
||
fontSize: readablePx(),
|
||
fontFamily: "Hack, Menlo, ui-monospace, monospace",
|
||
scrollback: 5000,
|
||
cursorBlink: false,
|
||
theme: terminalTheme(),
|
||
// Nothing reaches the pane from xterm itself. Keys come through `typing`
|
||
// and the key bar (iOS input methods never commit into xterm's own
|
||
// textarea). And xterm's answers to a program's queries — colours,
|
||
// device attributes, the cursor's position — stay unsent: the desktop,
|
||
// which owns the pane, answers those already, and a second answer from
|
||
// here arrives late, on a replay, and lands in the shell as typed text.
|
||
disableStdin: true,
|
||
// The search add-on's match highlights.
|
||
allowProposedApi: true,
|
||
});
|
||
|
||
const search = new SearchAddon();
|
||
term.loadAddon(search);
|
||
const findOptions = { caseSensitive: false, decorations: {
|
||
matchOverviewRuler: "#888888",
|
||
activeMatchColorOverviewRuler: "#ff9f0a",
|
||
matchBackground: "#ffd60a55",
|
||
activeMatchBackground: "#ff9f0a",
|
||
} };
|
||
search.onDidChangeResults(({ resultIndex, resultCount }) => {
|
||
findCount.textContent = findInput.value
|
||
? resultCount
|
||
? `${resultIndex + 1} of ${resultCount}`
|
||
: "None"
|
||
: "";
|
||
});
|
||
const finding = (on: boolean) => {
|
||
findBar.hidden = !on;
|
||
if (on) findInput.focus();
|
||
else {
|
||
search.clearDecorations();
|
||
term.clearSelection();
|
||
findInput.value = "";
|
||
findCount.textContent = "";
|
||
}
|
||
};
|
||
// Up is back through the scrollback, as the pane reads.
|
||
const find = (back: boolean) => {
|
||
if (!findInput.value) return;
|
||
if (back) search.findPrevious(findInput.value, findOptions);
|
||
else search.findNext(findInput.value, findOptions);
|
||
};
|
||
findInput.oninput = () => {
|
||
if (findInput.value) {
|
||
search.findPrevious(findInput.value, { ...findOptions, incremental: true });
|
||
} else {
|
||
search.clearDecorations();
|
||
term.clearSelection();
|
||
findCount.textContent = "";
|
||
}
|
||
};
|
||
findInput.onkeydown = (e) => {
|
||
if (e.key === "Enter") find(!e.shiftKey);
|
||
};
|
||
findPrev.onclick = () => find(true);
|
||
findNext.onclick = () => find(false);
|
||
findDone.onclick = () => finding(false);
|
||
|
||
// A program copying (OSC 52: tmux, an editor, an agent's "copy") gets a
|
||
// Copy button rather than the clipboard outright: a replay repeats old
|
||
// copies, and iOS only lets a tap write the clipboard anyway.
|
||
term.parser.registerOscHandler(52, (data) => {
|
||
const b64 = data.slice(data.indexOf(";") + 1);
|
||
if (!b64 || b64 === "?" || !live) return true;
|
||
let text: string;
|
||
try {
|
||
text = new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
|
||
} catch {
|
||
return true;
|
||
}
|
||
showBanner("The pane copied some text.", {
|
||
label: "Copy",
|
||
run: () => {
|
||
void navigator.clipboard?.writeText(text).catch(() => {});
|
||
clearBanner();
|
||
},
|
||
});
|
||
return true;
|
||
});
|
||
|
||
let handle: number | null = null;
|
||
// Whether keystrokes land: set by a successful open, cleared by anything
|
||
// that says they no longer do.
|
||
let live = false;
|
||
let ctrl = false;
|
||
let cols = 80;
|
||
let alive = true;
|
||
// Whether the pane is wider than the phone can show legibly.
|
||
let cramped = false;
|
||
// The pane exited: nothing to reconnect to.
|
||
let ended = false;
|
||
// The phone's size: `wanted` is what the user asked for, `leased` what
|
||
// the daemon confirmed, `sent` the grid last asked for.
|
||
let wanted = false;
|
||
let leased = false;
|
||
let releasing = false;
|
||
let sent = "";
|
||
let ctrlKey: HTMLButtonElement | null = null;
|
||
|
||
// Typing that does not get through is said, never dropped quietly: the
|
||
// pane goes offline with a way back, rather than looking live.
|
||
// A drop is retried on its own, sooner at first; the pane stays on
|
||
// screen as it was until the new stream replaces it.
|
||
const retry = retrier(() => reopen());
|
||
const offline = (message: string) => {
|
||
live = false;
|
||
setState("connecting", "Reconnecting");
|
||
showBanner(`${sentence(message)} Reconnecting…`, { label: "Try now", run: reopen });
|
||
retry.schedule();
|
||
};
|
||
// Only the first refusal speaks: a key typed just before it fails on its
|
||
// own, with a vaguer reason.
|
||
const refused = (message: string) => {
|
||
if (alive && live) offline(message);
|
||
};
|
||
const input = (data: string): Promise<boolean> => {
|
||
if (handle === null || !live) return Promise.resolve(false);
|
||
return api.paneInput(handle, data).then(
|
||
() => true,
|
||
(e) => {
|
||
refused(errorText(e));
|
||
return false;
|
||
},
|
||
);
|
||
};
|
||
const send = (data: string) => {
|
||
if (ctrl && data.length === 1) {
|
||
const c = data.toUpperCase().charCodeAt(0);
|
||
if (c >= 64 && c <= 95) data = String.fromCharCode(c - 64);
|
||
ctrl = false;
|
||
ctrlKey?.classList.remove("on");
|
||
}
|
||
// Typing brings a pane scrolled back into its history to the prompt.
|
||
term.scrollToBottom();
|
||
showCursor();
|
||
void input(data);
|
||
};
|
||
|
||
const canPaste = typeof navigator.clipboard?.readText === "function";
|
||
const paste = async () => {
|
||
let text: string;
|
||
try {
|
||
text = await navigator.clipboard.readText();
|
||
} catch {
|
||
return; // Declined at the system's paste prompt.
|
||
}
|
||
if (!text) return;
|
||
if (document.activeElement === field) {
|
||
field.setRangeText(text, field.selectionStart, field.selectionEnd, "end");
|
||
edited();
|
||
} else {
|
||
const body = text.replace(/\r?\n/g, "\r");
|
||
send(term.modes.bracketedPasteMode ? `\x1b[200~${body}\x1b[201~` : body);
|
||
}
|
||
};
|
||
|
||
for (const page of keyPages()) {
|
||
const row = h("div", { class: "key-page" });
|
||
for (const k of page) {
|
||
if (k.paste && !canPaste) continue;
|
||
const key = h("button", { class: "key", ariaLabel: k.label }, k.icon ? ico(k.icon) : (k.text ?? k.label));
|
||
key.style.flex = String(k.flex ?? 1);
|
||
if (k.latch) ctrlKey = key;
|
||
// Keep focus where it is — the terminal, the message box or neither —
|
||
// so the soft keyboard stays as it is.
|
||
key.onpointerdown = (e) => e.preventDefault();
|
||
key.onclick = () => {
|
||
if (k.latch) {
|
||
ctrl = !ctrl;
|
||
key.classList.toggle("on", ctrl);
|
||
} else if (k.paste) void paste();
|
||
else send(k.seq);
|
||
};
|
||
row.append(key);
|
||
}
|
||
pages.append(row);
|
||
dots.append(h("span", {}));
|
||
}
|
||
const showPage = () => {
|
||
const at = Math.round(pages.scrollLeft / Math.max(1, pages.clientWidth));
|
||
[...dots.children].forEach((dot, i) => dot.classList.toggle("on", i === at));
|
||
};
|
||
pages.addEventListener("scroll", showPage, { passive: true });
|
||
showPage();
|
||
|
||
const grow = () => {
|
||
// Off the page it measures nothing; the first fit comes once it is on.
|
||
if (!field.isConnected) return;
|
||
field.style.height = "auto";
|
||
field.style.height = `${field.scrollHeight}px`;
|
||
};
|
||
const edited = () => {
|
||
if (field.value) drafts.set(draftKey, field.value);
|
||
else drafts.delete(draftKey);
|
||
// Written, the round button sends; empty, it is the keyboard's.
|
||
sendKey.hidden = !field.value;
|
||
keyboard.hidden = !!field.value;
|
||
historyKey.hidden = !!field.value;
|
||
grow();
|
||
offer();
|
||
};
|
||
const offer = () => {
|
||
const found = document.activeElement === field ? suggestions(field.value) : [];
|
||
suggest.replaceChildren(
|
||
...found.map((past) => {
|
||
const chip = h("button", { class: "suggestion" }, past);
|
||
chip.onpointerdown = (e) => e.preventDefault();
|
||
chip.onclick = () => {
|
||
field.value = past;
|
||
edited();
|
||
};
|
||
return chip;
|
||
}),
|
||
);
|
||
suggest.hidden = found.length === 0;
|
||
suggest.scrollLeft = 0;
|
||
pages.classList.toggle("covered", found.length > 0 || !answers.hidden);
|
||
dots.classList.toggle("covered", found.length > 0 || !answers.hidden);
|
||
};
|
||
field.addEventListener("focus", offer);
|
||
field.addEventListener("blur", offer);
|
||
|
||
historyKey.onpointerdown = (e) => e.preventDefault();
|
||
historyKey.onclick = () => {
|
||
const all = sentHistory();
|
||
const search = h("input", {
|
||
type: "search",
|
||
class: "search-input",
|
||
placeholder: "Search history",
|
||
enterKeyHint: "search",
|
||
autocapitalize: "off",
|
||
spellcheck: false,
|
||
ariaLabel: "Search history",
|
||
});
|
||
search.setAttribute("autocorrect", "off");
|
||
const list = h("div", { class: "card" });
|
||
const empty = h("p", { class: "group-empty" });
|
||
const fill = () => {
|
||
const q = search.value.trim().toLowerCase();
|
||
const shown = all.filter((past) => past.toLowerCase().includes(q));
|
||
list.replaceChildren(
|
||
...shown.map((past) =>
|
||
h(
|
||
"button",
|
||
{
|
||
class: "row choice past",
|
||
onclick: () => {
|
||
remove();
|
||
field.value = past;
|
||
edited();
|
||
field.focus({ preventScroll: true });
|
||
},
|
||
},
|
||
h("span", { class: "row-title" }, past),
|
||
),
|
||
),
|
||
);
|
||
list.hidden = shown.length === 0;
|
||
empty.hidden = shown.length > 0;
|
||
empty.textContent = all.length
|
||
? `Nothing sent matches “${search.value.trim()}”.`
|
||
: "What you send from the message box shows up here.";
|
||
};
|
||
search.oninput = fill;
|
||
search.onkeydown = (e) => {
|
||
if (e.key === "Enter") search.blur();
|
||
};
|
||
const { remove } = openSheet(
|
||
"History",
|
||
h("label", { class: "search sheet-search" }, ico("search"), search),
|
||
h("div", { class: "sheet-body" }, list, empty),
|
||
);
|
||
fill();
|
||
};
|
||
attachKey.onpointerdown = (e) => e.preventDefault();
|
||
attachKey.onclick = () => {
|
||
if (place) {
|
||
showBanner(`Files can only go to panes on ${host.name} itself for now.`, { label: "OK", run: clearBanner });
|
||
return;
|
||
}
|
||
picker.value = "";
|
||
picker.click();
|
||
};
|
||
picker.onchange = async () => {
|
||
const files = [...(picker.files ?? [])];
|
||
if (!files.length) return;
|
||
attachKey.classList.add("busy");
|
||
attachKey.disabled = true;
|
||
const paths: string[] = [];
|
||
try {
|
||
for (const file of files) {
|
||
if (file.size > MAX_UPLOAD) {
|
||
throw new Error(`${file.name} is ${Math.ceil(file.size / 2 ** 20)} MB; files up to ${MAX_UPLOAD / 2 ** 20} MB can be sent`);
|
||
}
|
||
paths.push(await api.upload(host.id, null, file.name, new Uint8Array(await file.arrayBuffer())));
|
||
}
|
||
} catch (e) {
|
||
showBanner(`Couldn't send the file: ${sentence(errorText(e))}`, { label: "OK", run: clearBanner });
|
||
} finally {
|
||
attachKey.classList.remove("busy");
|
||
attachKey.disabled = false;
|
||
}
|
||
if (!paths.length) return;
|
||
// Its own word, before and after, so it reads as a path whatever is
|
||
// written around it.
|
||
const before = field.value && !/\s$/.test(field.value) ? `${field.value} ` : field.value;
|
||
field.value = `${before}${paths.join(" ")} `;
|
||
edited();
|
||
field.focus({ preventScroll: true });
|
||
};
|
||
field.addEventListener("input", edited);
|
||
// A password typed into the box is sent, never kept: the line the cursor
|
||
// is on says what the pane asked for.
|
||
const answersSecret = () => {
|
||
const buf = term.buffer.active;
|
||
const line = buf.getLine(buf.baseY + buf.cursorY)?.translateToString(true) ?? "";
|
||
return /pass(word|phrase)|\bpin\b|密码|口令/i.test(line);
|
||
};
|
||
const submit = async () => {
|
||
const body = field.value.replace(/\r?\n/g, "\r");
|
||
// Several lines go in as one paste where the program asked for that,
|
||
// so an agent takes them as one message and a shell does not run each.
|
||
const data = body.includes("\r") && term.modes.bracketedPasteMode ? `\x1b[200~${body}\x1b[201~` : body;
|
||
if (data && !(await input(data))) return;
|
||
// Enter as its own write: a program that tells pasting from typing by
|
||
// how the bytes arrive would otherwise take it as part of the text.
|
||
// An empty box sends Enter alone.
|
||
if (!(await input("\r"))) return;
|
||
if (!answersSecret()) keepSent(field.value);
|
||
field.value = "";
|
||
edited();
|
||
};
|
||
field.addEventListener("keydown", (e) => {
|
||
// Enter that confirms an IME's candidate is the IME's, not a send.
|
||
if (e.key !== "Enter" || e.shiftKey || e.isComposing || e.keyCode === 229) return;
|
||
e.preventDefault();
|
||
void submit();
|
||
});
|
||
sendKey.onpointerdown = (e) => e.preventDefault();
|
||
sendKey.onclick = () => void submit();
|
||
|
||
keyboard.onpointerdown = (e) => e.preventDefault();
|
||
keyboard.onclick = () => {
|
||
if (document.activeElement === typing) typing.blur();
|
||
else typing.focus({ preventScroll: true });
|
||
};
|
||
typing.addEventListener("focus", () => keyboard.classList.add("on"));
|
||
typing.addEventListener("blur", () => keyboard.classList.remove("on"));
|
||
|
||
// Committed text goes to the pane and the field is emptied again; while
|
||
// an input method is composing, the field holds the candidate.
|
||
let imeOpen = false;
|
||
const flush = () => {
|
||
if (imeOpen || !typing.value) return;
|
||
const text = typing.value.replace(/\r?\n/g, "\r");
|
||
typing.value = "";
|
||
send(text);
|
||
};
|
||
typing.addEventListener("compositionstart", () => (imeOpen = true));
|
||
typing.addEventListener("compositionend", () => {
|
||
imeOpen = false;
|
||
// The committed text is in the field after this event, not during it.
|
||
setTimeout(flush);
|
||
});
|
||
typing.addEventListener("input", (e) => {
|
||
if (!(e as InputEvent).isComposing) flush();
|
||
});
|
||
// The keys a field would keep to itself, as the terminal's: a Backspace on
|
||
// the empty field is the pane's, and so on. An input method's own Enter
|
||
// and Backspace (keyCode 229) stay with it.
|
||
const TYPING_KEYS: Record<string, string> = {
|
||
Backspace: "\x7f",
|
||
Enter: "\r",
|
||
Tab: "\t",
|
||
Escape: "\x1b",
|
||
ArrowUp: "\x1b[A",
|
||
ArrowDown: "\x1b[B",
|
||
ArrowRight: "\x1b[C",
|
||
ArrowLeft: "\x1b[D",
|
||
};
|
||
typing.addEventListener("keydown", (e) => {
|
||
if (imeOpen || e.isComposing || e.keyCode === 229) return;
|
||
const seq = TYPING_KEYS[e.key];
|
||
if (!seq || (e.key === "Backspace" && typing.value)) return;
|
||
e.preventDefault();
|
||
send(e.shiftKey && e.key === "Tab" ? "\x1b[Z" : seq);
|
||
});
|
||
|
||
// Two ways to show a pane wider than the phone. Fitted, the font shrinks
|
||
// until every column shows; readable, the font stays legible and the view
|
||
// pans sideways, following the cursor. A pane that fits legibly is simply
|
||
// fitted, and the toggle has nothing to offer.
|
||
let readable = prefs.wide === "readable";
|
||
const fittedSize = () => {
|
||
const width = screenEl.clientWidth - 12;
|
||
return width <= 0 ? readablePx() : Math.min(Math.max(14, readablePx()), Math.floor((width / cols / CELL_EM) * 10) / 10);
|
||
};
|
||
const fit = () => {
|
||
// Taken over, the pane is exactly the phone's width at the readable
|
||
// size: nothing to shrink, nothing to pan.
|
||
if (leased) {
|
||
cramped = false;
|
||
term.options.fontSize = readablePx();
|
||
screenEl.classList.remove("panning");
|
||
return;
|
||
}
|
||
const fitted = fittedSize();
|
||
cramped = fitted < readablePx();
|
||
term.options.fontSize = cramped && readable ? readablePx() : Math.max(4, fitted);
|
||
screenEl.classList.toggle("panning", cramped && readable);
|
||
follow();
|
||
// The new font size is laid out on the next frame.
|
||
requestAnimationFrame(() => {
|
||
pinScrollbar();
|
||
fill();
|
||
settle();
|
||
});
|
||
};
|
||
// A pane shorter than the view (a wide one, fitted) gets rows of its
|
||
// history above it, as many as fill the view: the terminal here runs
|
||
// taller than the pane. The pane's own output goes on as it would —
|
||
// a shell's lines run down to the bottom, then scroll — and growing
|
||
// the grid brings earlier lines back down out of the scrollback. A
|
||
// full-screen program draws in the pane's rows at the top.
|
||
let paneRows = 24;
|
||
let fillRows = 0;
|
||
const fill = () => {
|
||
// With the keyboard up the view is short for a while; the grid stays
|
||
// as it is, and the box scrolls instead.
|
||
if (document.documentElement.classList.contains("keyboard")) return;
|
||
screenEl.style.paddingTop = "";
|
||
const style = getComputedStyle(screenEl);
|
||
const room = screenEl.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
||
fillRows = leased ? 0 : Math.floor(room / rowHeight());
|
||
const rows = Math.max(paneRows, fillRows);
|
||
if (rows !== term.rows) term.resize(cols, rows);
|
||
};
|
||
// A pane shorter than the view (a wide one, fitted) sits at the bottom,
|
||
// its prompt next to the keys, as a terminal's does: the room above it
|
||
// goes into the box's top padding.
|
||
const settle = () => {
|
||
const box = screenEl.querySelector<HTMLElement>(".xterm");
|
||
if (!box) return;
|
||
screenEl.style.paddingTop = "";
|
||
const style = getComputedStyle(screenEl);
|
||
const pad = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
||
const room = screenEl.clientHeight - pad - box.offsetHeight;
|
||
if (room > 0) screenEl.style.paddingTop = `${parseFloat(style.paddingTop) + room}px`;
|
||
};
|
||
// xterm puts its scrollbar at the right of its own box, which pans with
|
||
// the text; shift it so it stays at the right of what is on screen. The
|
||
// `translate` property, not `transform`, so it never fights xterm's own
|
||
// styling of the bar.
|
||
const pinScrollbar = () => {
|
||
const box = screenEl.querySelector<HTMLElement>(".xterm");
|
||
const bar = box?.querySelector<HTMLElement>(".xterm-scrollable-element > .scrollbar.vertical");
|
||
if (!box || !bar) return;
|
||
if (!screenEl.classList.contains("panning")) {
|
||
bar.style.translate = "";
|
||
return;
|
||
}
|
||
const edge = screenEl.scrollLeft + screenEl.clientWidth - parseFloat(getComputedStyle(screenEl).paddingRight);
|
||
bar.style.translate = `${Math.min(0, edge - (box.offsetLeft + box.offsetWidth))}px 0`;
|
||
};
|
||
screenEl.addEventListener("scroll", pinScrollbar, { passive: true });
|
||
|
||
// Scrolling the scrollback by touch. xterm 6 carries a gesture recogniser
|
||
// but registers nothing with it, so a swipe over the terminal never
|
||
// reaches the scrollback. A swipe is claimed by its first few points:
|
||
// mostly vertical, it scrolls the buffer here, a row at a time, and
|
||
// coasts after the finger lifts; mostly sideways, it is left to the
|
||
// native pan of a pane wider than the phone.
|
||
let touch: { x: number; y: number; axis: "x" | "y" | null; samples: [number, number][] } | null = null;
|
||
let coast = 0;
|
||
// Finger movement not yet applied, and the frame that will apply it.
|
||
let pending = 0;
|
||
let frame = 0;
|
||
// The web address under a point on the screen, if any: the cell it falls
|
||
// in, then the line that cell is part of, wrapped rows joined.
|
||
const linkAt = (x: number, y: number) => {
|
||
const drawn = screenEl.querySelector<HTMLElement>(".xterm-screen");
|
||
if (!drawn || !term.cols || !term.rows) return null;
|
||
const box = drawn.getBoundingClientRect();
|
||
const col = Math.floor(((x - box.left) / box.width) * term.cols);
|
||
const row = Math.floor(((y - box.top) / box.height) * term.rows);
|
||
if (col < 0 || row < 0 || col >= term.cols || row >= term.rows) return null;
|
||
const buf = term.buffer.active;
|
||
const at = buf.viewportY + row;
|
||
let start = at;
|
||
while (start > 0 && buf.getLine(start)?.isWrapped) start--;
|
||
let text = "";
|
||
for (let y2 = start; y2 === start || buf.getLine(y2)?.isWrapped; y2++) {
|
||
const line = buf.getLine(y2);
|
||
if (!line) break;
|
||
text += line.translateToString(false);
|
||
}
|
||
const offset = (at - start) * term.cols + col;
|
||
for (const m of text.matchAll(/https?:\/\/[^\s<>"'`]+/g)) {
|
||
const url = m[0].replace(/[.,;:!?)\]}'"]+$/, "");
|
||
if (offset >= m.index && offset < m.index + url.length) return url;
|
||
}
|
||
return null;
|
||
};
|
||
const rowHeight = () => {
|
||
const drawn = screenEl.querySelector<HTMLElement>(".xterm-screen");
|
||
return drawn && term.rows ? drawn.clientHeight / term.rows : 14;
|
||
};
|
||
// Scrolls the pane's box so the cursor's line shows, when the pane is
|
||
// taller than the room left (the keyboard is up). Not while reading back
|
||
// through history.
|
||
const showCursor = () => {
|
||
const buf = term.buffer.active;
|
||
if (buf.viewportY < buf.baseY) return;
|
||
const pad = parseFloat(getComputedStyle(screenEl).paddingTop) || 0;
|
||
const top = pad + buf.cursorY * rowHeight();
|
||
const bottom = top + rowHeight() + pad;
|
||
if (bottom > screenEl.scrollTop + screenEl.clientHeight) screenEl.scrollTop = bottom - screenEl.clientHeight;
|
||
else if (top < screenEl.scrollTop) screenEl.scrollTop = Math.max(0, top - pad);
|
||
};
|
||
// How far past the top row's top edge the view is, in pixels: the part
|
||
// of a row a swipe has moved that xterm, which scrolls whole rows, cannot
|
||
// show. The drawn screen is shifted up by it, so the text follows the
|
||
// finger pixel by pixel instead of jumping a row at a time. The GPU moves
|
||
// the image; nothing is redrawn for it.
|
||
let frac = 0;
|
||
// The shift waiting for xterm to draw the rows it goes with.
|
||
let shiftAfterRender: number | null = null;
|
||
const shift = (px: number) => {
|
||
const drawn = screenEl.querySelector<HTMLElement>(".xterm-screen");
|
||
if (drawn) drawn.style.transform = px ? `translate3d(0, ${-px}px, 0)` : "";
|
||
};
|
||
const setFrac = (px: number, afterRender = false) => {
|
||
frac = px;
|
||
// xterm draws a scroll on its next frame, not at once. A shift for the
|
||
// new rows put on the old ones would jolt the text a row for a frame,
|
||
// on every row crossed; so it waits for the draw.
|
||
if (afterRender) shiftAfterRender = px;
|
||
else {
|
||
shiftAfterRender = null;
|
||
shift(px);
|
||
}
|
||
};
|
||
term.onRender(() => {
|
||
if (shiftAfterRender === null) return;
|
||
shift(shiftAfterRender);
|
||
shiftAfterRender = null;
|
||
});
|
||
// Anything else that scrolls the buffer — output, typing — lands on a
|
||
// whole row.
|
||
let ownScroll = false;
|
||
term.onScroll(() => {
|
||
if (!ownScroll && frac) setFrac(0);
|
||
});
|
||
// Moves the view by a distance in pixels. Dragging down goes back in the
|
||
// scrollback.
|
||
const scrollBy = (dy: number) => {
|
||
// With the keyboard up the pane can be taller than what is left of the
|
||
// screen, and its box scrolls too: back through history, the box goes
|
||
// to its top before the buffer moves; forward, the buffer comes to the
|
||
// prompt before the box goes to its bottom.
|
||
const buf = term.buffer.active;
|
||
const boxRoom = screenEl.scrollHeight - screenEl.clientHeight;
|
||
if ((dy > 0 && screenEl.scrollTop > 0) || (dy < 0 && buf.viewportY >= buf.baseY && screenEl.scrollTop < boxRoom)) {
|
||
screenEl.scrollTop -= dy;
|
||
return;
|
||
}
|
||
const row = rowHeight();
|
||
// Where the view is, from the top of the scrollback, and where it goes;
|
||
// the bottom is the prompt with nothing shifted.
|
||
const at = buf.viewportY * row + frac;
|
||
const to = Math.min(Math.max(0, at - dy), buf.baseY * row);
|
||
const line = Math.floor(to / row);
|
||
const crossed = line !== buf.viewportY;
|
||
if (crossed) {
|
||
ownScroll = true;
|
||
term.scrollToLine(line);
|
||
ownScroll = false;
|
||
}
|
||
setFrac(to - line * row, crossed);
|
||
};
|
||
screenEl.addEventListener(
|
||
"touchstart",
|
||
(e) => {
|
||
cancelAnimationFrame(coast);
|
||
cancelAnimationFrame(frame);
|
||
frame = 0;
|
||
pending = 0;
|
||
if (e.touches.length !== 1) return (touch = null);
|
||
// The scrollbar's thumb is dragged by xterm itself, the other way
|
||
// round from a swipe: both at once cancel out and it will not move.
|
||
if ((e.target as Element).closest?.(".scrollbar")) return (touch = null);
|
||
const t = e.touches[0];
|
||
touch = { x: t.clientX, y: t.clientY, axis: null, samples: [[t.clientY, e.timeStamp]] };
|
||
},
|
||
{ passive: true },
|
||
);
|
||
screenEl.addEventListener(
|
||
"touchmove",
|
||
(e) => {
|
||
if (!touch || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
if (!touch.axis) {
|
||
const dx = Math.abs(t.clientX - touch.x);
|
||
const dy = Math.abs(t.clientY - touch.y);
|
||
if (Math.max(dx, dy) < 6) return;
|
||
touch.axis = dy > dx ? "y" : "x";
|
||
}
|
||
if (touch.axis !== "y") return;
|
||
e.preventDefault();
|
||
const last = touch.samples[touch.samples.length - 1][0];
|
||
pending += t.clientY - last;
|
||
touch.samples.push([t.clientY, e.timeStamp]);
|
||
if (touch.samples.length > 5) touch.samples.shift();
|
||
// A 120 Hz screen sends a move every 8 ms; the terminal redraws once
|
||
// a frame, with all of them.
|
||
if (!frame)
|
||
frame = requestAnimationFrame(() => {
|
||
frame = 0;
|
||
if (touch) scrollBy(pending);
|
||
pending = 0;
|
||
});
|
||
},
|
||
{ passive: false },
|
||
);
|
||
screenEl.addEventListener(
|
||
"touchend",
|
||
(e) => {
|
||
const lifted = touch;
|
||
touch = null;
|
||
// A tap: the keyboard comes up for typing into the pane. The mouse
|
||
// events the tap would turn into are cancelled, or xterm would move
|
||
// focus to its own textarea.
|
||
if (lifted && !lifted.axis && e.cancelable) {
|
||
e.preventDefault();
|
||
// A link under the finger opens in the browser instead.
|
||
const at = e.changedTouches[0];
|
||
const url = at && linkAt(at.clientX, at.clientY);
|
||
if (url) void openUrl(url).catch(() => {});
|
||
else typing.focus({ preventScroll: true });
|
||
return;
|
||
}
|
||
if (lifted?.axis === "y") glide(lifted);
|
||
},
|
||
{ passive: false },
|
||
);
|
||
// Pinching: out to read closer, in to see more. A step at a time, on
|
||
// lifting — from the whole width to the readable size, then through the
|
||
// text sizes Settings offers; the chosen size is kept as the setting.
|
||
let pinch: { from: number; to: number } | null = null;
|
||
const spread = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
|
||
screenEl.addEventListener(
|
||
"touchstart",
|
||
(e) => {
|
||
if (e.touches.length === 2) pinch = { from: spread(e.touches), to: spread(e.touches) };
|
||
},
|
||
{ passive: true },
|
||
);
|
||
screenEl.addEventListener(
|
||
"touchmove",
|
||
(e) => {
|
||
if (!pinch || e.touches.length !== 2) return;
|
||
e.preventDefault();
|
||
pinch.to = spread(e.touches);
|
||
},
|
||
{ passive: false },
|
||
);
|
||
screenEl.addEventListener("touchend", (e) => {
|
||
if (!pinch || e.touches.length >= 2) return;
|
||
const scale = pinch.to / Math.max(1, pinch.from);
|
||
pinch = null;
|
||
if (scale > 0.87 && scale < 1.15) return;
|
||
const closer = scale > 1;
|
||
if (cramped && !leased && readable !== closer) {
|
||
toggleZoom();
|
||
return;
|
||
}
|
||
const sizes = TEXT_SIZES.map((t) => t.value);
|
||
const at = sizes.indexOf(prefs.textSize);
|
||
const next = sizes[Math.min(sizes.length - 1, Math.max(0, (at < 0 ? 1 : at) + (closer ? 1 : -1)))];
|
||
if (next === prefs.textSize) return;
|
||
setPref("textSize", next);
|
||
fit();
|
||
askLease();
|
||
});
|
||
const glide = (lifted: NonNullable<typeof touch>) => {
|
||
if (lifted.samples.length < 2) return;
|
||
const [y0, t0] = lifted.samples[0];
|
||
const [y1, t1] = lifted.samples[lifted.samples.length - 1];
|
||
// Pixels per millisecond at the lift, eased out over about a second.
|
||
let velocity = (y1 - y0) / Math.max(1, t1 - t0);
|
||
let at = performance.now();
|
||
const step = (now: number) => {
|
||
if (!alive) return;
|
||
const dt = now - at;
|
||
at = now;
|
||
velocity *= Math.pow(0.995, dt);
|
||
if (Math.abs(velocity) < 0.02) return;
|
||
scrollBy(velocity * dt);
|
||
coast = requestAnimationFrame(step);
|
||
};
|
||
coast = requestAnimationFrame(step);
|
||
};
|
||
const follow = () => {
|
||
if (!screenEl.classList.contains("panning")) return;
|
||
const cell = screenEl.scrollWidth / cols;
|
||
const x = term.buffer.active.cursorX * cell;
|
||
const view = screenEl.clientWidth;
|
||
if (x < screenEl.scrollLeft + cell * 4 || x > screenEl.scrollLeft + view - cell * 4)
|
||
screenEl.scrollLeft = Math.max(0, x - view / 2);
|
||
};
|
||
const toggleZoom = () => {
|
||
readable = !readable;
|
||
fit();
|
||
};
|
||
|
||
// Taking the pane over (the lets are with the others above): it runs at
|
||
// the phone's grid while this screen is open, and the desktop keeps its
|
||
// window and can take it back.
|
||
const takeGrid = () => {
|
||
const width = screenEl.clientWidth - 12;
|
||
const height = screenEl.clientHeight - 16;
|
||
// A row's height, measured off what xterm drew and scaled to the size
|
||
// the pane will be read at; the metrics guess only before the first draw.
|
||
const drawn = screenEl.querySelector<HTMLElement>(".xterm-screen");
|
||
const now = term.options.fontSize ?? readablePx();
|
||
const rowH =
|
||
drawn && term.rows && drawn.clientHeight
|
||
? ((drawn.clientHeight / term.rows) * readablePx()) / now
|
||
: readablePx() * 1.18;
|
||
return {
|
||
cols: Math.max(20, Math.floor(width / (readablePx() * CELL_EM))),
|
||
rows: Math.max(5, Math.floor(height / rowH)),
|
||
};
|
||
};
|
||
const askLease = () => {
|
||
if (handle === null || !live || !wanted) return;
|
||
const grid = takeGrid();
|
||
const key = `${grid.cols}x${grid.rows}`;
|
||
if (leased && key === sent) return;
|
||
sent = key;
|
||
api.paneLease(handle, grid).catch(() => {});
|
||
};
|
||
// Taken over is said under the title, beside Live.
|
||
const liveLabel = () => (leased ? "Live · phone size" : "Live");
|
||
const toggleTake = () => {
|
||
wanted = !wanted;
|
||
if (wanted) {
|
||
sent = "";
|
||
askLease();
|
||
} else if (handle !== null && leased) {
|
||
releasing = true;
|
||
api.paneLease(handle, null).catch(() => {});
|
||
}
|
||
};
|
||
// The keyboard coming up or the phone turning changes the grid; asked
|
||
// for once it settles, not on every step of the animation.
|
||
let regrid: number | undefined;
|
||
const regridSoon = () => {
|
||
clearTimeout(regrid);
|
||
if (wanted) regrid = window.setTimeout(askLease, 250);
|
||
};
|
||
const leaseEvent = (held: boolean, refused: string | null | undefined) => {
|
||
const was = leased;
|
||
leased = held;
|
||
if (held) {
|
||
banner.replaceChildren();
|
||
} else {
|
||
sent = "";
|
||
const ours = releasing;
|
||
releasing = false;
|
||
if (refused) {
|
||
wanted = false;
|
||
showBanner(sentence(refused));
|
||
} else if (was && !ours) {
|
||
// Nobody here let go: the desktop took it back, or another device
|
||
// took it over. Asking again is one tap, not automatic.
|
||
wanted = false;
|
||
showBanner("The desktop took this pane back.", {
|
||
label: "Take over again",
|
||
run: () => {
|
||
banner.replaceChildren();
|
||
toggleTake();
|
||
},
|
||
});
|
||
}
|
||
}
|
||
if (live) setState("live", liveLabel());
|
||
fit();
|
||
};
|
||
term.onCursorMove(follow);
|
||
// Output running on below what the keyboard leaves showing: kept in sight.
|
||
term.onCursorMove(() => {
|
||
if (document.documentElement.classList.contains("keyboard")) showCursor();
|
||
});
|
||
// The whole buffer, scrollback included, as lines of text. A row the
|
||
// terminal wrapped joins the one before it, so a long URL or path comes
|
||
// out whole; its trailing blanks are real and kept.
|
||
const bufferText = () => {
|
||
const buf = term.buffer.active;
|
||
const lines: string[] = [];
|
||
for (let y = 0; y < buf.length; y++) {
|
||
const line = buf.getLine(y);
|
||
if (!line) continue;
|
||
const text = line.translateToString(!buf.getLine(y + 1)?.isWrapped);
|
||
if (line.isWrapped && lines.length) lines[lines.length - 1] += text;
|
||
else lines.push(text);
|
||
}
|
||
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
||
return lines.join("\n");
|
||
};
|
||
const selecting = (on: boolean) => {
|
||
copyView.hidden = !on;
|
||
if (!on) {
|
||
getSelection()?.removeAllRanges();
|
||
copyText.textContent = "";
|
||
return;
|
||
}
|
||
typing.blur();
|
||
field.blur();
|
||
copyText.textContent = bufferText();
|
||
copyText.scrollTop = copyText.scrollHeight;
|
||
};
|
||
copyDone.onclick = () => selecting(false);
|
||
|
||
const retheme = () => {
|
||
term.options.theme = terminalTheme();
|
||
};
|
||
window.addEventListener("resize", fit);
|
||
window.addEventListener("resize", regridSoon);
|
||
// The keyboard came up or went down: the pane's box grew or shrank, so a
|
||
// taken-over pane regrids, and the cursor's line is kept in sight.
|
||
const viewport = () => {
|
||
regridSoon();
|
||
requestAnimationFrame(() => {
|
||
settle();
|
||
showCursor();
|
||
});
|
||
};
|
||
// The pane's grid changed: its height with it.
|
||
term.onResize(() => requestAnimationFrame(settle));
|
||
window.addEventListener("viewport", viewport);
|
||
window.addEventListener("theme", retheme);
|
||
|
||
const setState = (cls: string, label: string) => {
|
||
state.className = `term-state ${cls}`;
|
||
stateWord.textContent = label;
|
||
};
|
||
const clearBanner = () => banner.replaceChildren();
|
||
|
||
// The choices on screen, read off the bottom of the pane when the agent
|
||
// is waiting: the last run of lines numbered from 1, as Claude Code and
|
||
// Codex draw them ("❯ 1. Yes", "2. No, and tell Claude…"), box edges and
|
||
// the cursor mark stripped.
|
||
const readChoices = () => {
|
||
const found: { n: string; label: string }[] = [];
|
||
if (agentWaiting) {
|
||
const buf = term.buffer.active;
|
||
const bottom = buf.baseY + term.rows;
|
||
let run: typeof found = [];
|
||
for (let y = Math.max(0, bottom - 24); y < bottom; y++) {
|
||
const text = (buf.getLine(y)?.translateToString(true) ?? "")
|
||
.replace(/^[\s│┃|]+|[\s│┃|]+$/g, "")
|
||
.replace(/^[❯›>▸●◉○]\s*/, "");
|
||
const m = /^(\d{1,2})[.)]\s+(.+)$/.exec(text);
|
||
if (!m) continue;
|
||
if (m[1] === "1") run = [];
|
||
if (Number(m[1]) === run.length + 1) run.push({ n: m[1], label: m[2] });
|
||
if (run.length >= 2) found.splice(0, found.length, ...run);
|
||
}
|
||
}
|
||
const shown = answers.dataset.shown ?? "";
|
||
const now = found.map((c) => `${c.n}${c.label}`).join("\n");
|
||
if (shown === now) return;
|
||
answers.dataset.shown = now;
|
||
answers.replaceChildren(
|
||
...found.map((c) => {
|
||
const b = h("button", { class: "answer" }, h("b", {}, c.n), h("span", {}, c.label));
|
||
b.onpointerdown = (e) => e.preventDefault();
|
||
b.onclick = () => send(c.n);
|
||
return b;
|
||
}),
|
||
);
|
||
answers.hidden = found.length === 0;
|
||
answers.scrollLeft = 0;
|
||
pages.classList.toggle("covered", found.length > 0 || !suggest.hidden);
|
||
dots.classList.toggle("covered", found.length > 0 || !suggest.hidden);
|
||
};
|
||
let choicesFrame = 0;
|
||
term.onWriteParsed(() => {
|
||
if (!agentWaiting || choicesFrame) return;
|
||
choicesFrame = requestAnimationFrame(() => {
|
||
choicesFrame = 0;
|
||
readChoices();
|
||
});
|
||
});
|
||
const showBanner = (text: string, action?: { label: string; run: () => void }) =>
|
||
banner.replaceChildren(
|
||
h(
|
||
"div",
|
||
{ class: "term-banner" },
|
||
h("span", {}, text),
|
||
action && h("button", { class: "button tinted small", onclick: action.run }, action.label),
|
||
),
|
||
);
|
||
|
||
// Which open is current. A stream that was replaced may still deliver
|
||
// its last bytes or its error; those are not this pane's any more.
|
||
let generation = 0;
|
||
const open = async () => {
|
||
const mine = ++generation;
|
||
const current = () => alive && mine === generation;
|
||
// The screen is cleared for the replay when it starts, not before, so a
|
||
// retry that fails leaves the last good screen up.
|
||
let replayed = false;
|
||
const replay = () => {
|
||
if (replayed) return;
|
||
replayed = true;
|
||
term.reset();
|
||
};
|
||
live = false;
|
||
retry.cancel();
|
||
if (!banner.hasChildNodes()) setState("connecting", "Connecting");
|
||
try {
|
||
const opened = await api.paneOpen(
|
||
host.id,
|
||
place?.key ?? null,
|
||
pane.id,
|
||
(bytes) => {
|
||
if (!current()) return;
|
||
replay();
|
||
term.write(bytes);
|
||
},
|
||
(event) => {
|
||
if (!current()) return;
|
||
if (event.type === "size") replay();
|
||
switch (event.type) {
|
||
case "size":
|
||
cols = event.cols;
|
||
paneRows = event.rows;
|
||
term.resize(event.cols, Math.max(event.rows, leased ? 0 : fillRows));
|
||
fit();
|
||
break;
|
||
case "agent":
|
||
agentWaiting = event.agent?.status === "waiting";
|
||
readChoices();
|
||
field.placeholder = event.agent
|
||
? `Message ${agentLook(event.agent.kind).name}…`
|
||
: "Type a command…";
|
||
break;
|
||
case "cwd":
|
||
paneCwd = event.path;
|
||
cwd.textContent = where(event.path);
|
||
break;
|
||
case "exited":
|
||
ended = true;
|
||
live = false;
|
||
handle = null;
|
||
retry.cancel();
|
||
setState("offline", "Closed");
|
||
showBanner(
|
||
event.code === null ? "This pane closed." : `This pane exited with code ${event.code}.`,
|
||
);
|
||
break;
|
||
case "error":
|
||
offline(event.message);
|
||
break;
|
||
case "lease":
|
||
leaseEvent(event.held, event.refused);
|
||
break;
|
||
}
|
||
},
|
||
);
|
||
if (!current()) {
|
||
api.paneClose(opened);
|
||
return;
|
||
}
|
||
handle = opened;
|
||
live = true;
|
||
// A new stream starts at the desktop's size; the lease the old one
|
||
// held ended with it. Asked for again if it is still wanted.
|
||
leased = false;
|
||
sent = "";
|
||
askLease();
|
||
retry.reset();
|
||
banner.replaceChildren();
|
||
setState("live", liveLabel());
|
||
// A new tab's agent, started once: not again on a reconnect.
|
||
if (run) {
|
||
const command = run;
|
||
run = undefined;
|
||
void input(`${command}\r`);
|
||
}
|
||
} catch (e) {
|
||
if (current()) offline(errorText(e));
|
||
}
|
||
};
|
||
const reopen = () => {
|
||
if (handle !== null) api.paneClose(handle);
|
||
handle = null;
|
||
open();
|
||
};
|
||
const online = () => {
|
||
if (!live && !ended) reopen();
|
||
};
|
||
window.addEventListener("online", online);
|
||
|
||
onLeave = () => {
|
||
alive = false;
|
||
retry.cancel();
|
||
window.removeEventListener("online", online);
|
||
window.removeEventListener("resize", fit);
|
||
window.removeEventListener("resize", regridSoon);
|
||
window.removeEventListener("viewport", viewport);
|
||
clearTimeout(regrid);
|
||
window.removeEventListener("theme", retheme);
|
||
if (handle !== null) api.paneClose(handle);
|
||
term.dispose();
|
||
};
|
||
// The pane replays its screen on every open, so coming back from the
|
||
// background is a fresh open onto a reset terminal — never a gap.
|
||
onResume = reopen;
|
||
|
||
// Hack has to be loaded before xterm measures a cell, or the first fit is
|
||
// taken with the fallback face's metrics.
|
||
requestAnimationFrame(() => {
|
||
document.fonts.load("12px Hack").finally(() => {
|
||
if (!alive) return;
|
||
term.open(screenEl);
|
||
// Drawn on the GPU: the DOM renderer lays every row out again on each
|
||
// line scrolled, which a phone cannot do at the finger's pace. If the
|
||
// context is lost, xterm goes back to the DOM renderer.
|
||
try {
|
||
const webgl = new WebglAddon();
|
||
webgl.onContextLoss(() => webgl.dispose());
|
||
term.loadAddon(webgl);
|
||
} catch {
|
||
// No WebGL here: the DOM renderer stays.
|
||
}
|
||
fit();
|
||
edited();
|
||
open();
|
||
});
|
||
});
|
||
return view;
|
||
});
|
||
}
|
||
|
||
if (prefs.lock) lock();
|
||
hostsScreen("push");
|