diff --git a/mobile/index.html b/mobile/index.html
index 038cee6b..8e1b96fc 100644
--- a/mobile/index.html
+++ b/mobile/index.html
@@ -7,8 +7,8 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
-
-
+
+
tty7
diff --git a/mobile/src/icons.ts b/mobile/src/icons.ts
index 329f5146..2f057cbd 100644
--- a/mobile/src/icons.ts
+++ b/mobile/src/icons.ts
@@ -31,6 +31,9 @@ export const icon = {
scan: stroke(
``,
),
+ search: stroke(``),
+ close: stroke(``, 2),
+ check: stroke(``, 2.2),
enter: stroke(``, 2),
phone: stroke(``),
copy: stroke(``),
diff --git a/mobile/src/main.ts b/mobile/src/main.ts
index ccd64738..ed4415b2 100644
--- a/mobile/src/main.ts
+++ b/mobile/src/main.ts
@@ -98,6 +98,8 @@ interface ScreenParts {
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. */
@@ -130,7 +132,35 @@ function screen(parts: ScreenParts) {
([entry]) => bar.classList.toggle("folded", !entry.isIntersecting),
{ root: scroll, threshold: 0, rootMargin: "-8px 0px 0px 0px" },
).observe(large);
- return h("div", { class: "screen" }, bar, scroll, parts.footer);
+ const view = h("div", { class: "screen" }, bar, scroll, parts.footer, parts.dock);
+ if (parts.dock) view.classList.add("docked");
+ if (!parts.back && !parts.trailing?.length) 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[]) {
@@ -145,19 +175,43 @@ function section(title: Child, ...rows: Child[]) {
// ---------------------------------------------------------------------------
// 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();
+
+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" });
- const pairButton = h(
- "button",
- { class: "nav-icon", ariaLabel: "Pair a machine", onclick: () => pairScreen() },
- ico("plus"),
- );
- const view = screen({ title: "Machines", trailing: [pairButton], body: [body] });
+ 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", 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) {
- pairButton.hidden = true;
body.replaceChildren(
h(
"div",
@@ -174,25 +228,29 @@ function hostsScreen(direction: "push" | "pop" = "pop") {
);
return;
}
- body.replaceChildren(
- section(
- null,
- ...hosts.map((host) =>
- h(
- "button",
- { class: "row", onclick: () => hostScreen(host, "push") },
- h("span", { class: "tile" }, ico("machine")),
- h("span", { class: "row-text" }, h("span", { class: "row-title" }, host.name)),
- ico("chevron", "icon row-chevron"),
- ),
- ),
- ),
- );
+ dock.hidden = false;
+ render();
});
return view;
});
}
+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
@@ -390,13 +448,26 @@ const SLOW_CONNECT_MS = 10_000;
function hostScreen(host: Host, direction: "push" | "pop" = "pop") {
go(direction, () => {
const link = h("p", { class: "link" });
- renderLink(link, { path: "connecting", rtt_ms: 0 });
+ // 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 });
+ };
+ showLink({ path: "connecting", rtt_ms: 0 });
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 = "";
+ const draw = () => {
+ if (lastTree) body.replaceChildren(...renderTree(host, lastTree, query));
+ };
+ const dock = floatingBar("Search tabs", (q) => {
+ query = q;
+ draw();
+ }, { label: "New tab", run: () => lastTree && newTabSheet(host, lastTree, failed) });
const menu = menuButton([
{ label: "Refresh", icon: "refresh", run: () => api.refresh(host.id).catch(() => start()) },
@@ -419,6 +490,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") {
trailing: [menu],
subtitle: link,
body: [notice, body],
+ dock,
});
const retry = retrier(() => start());
@@ -445,7 +517,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") {
offlineNow = true;
dropped = true;
retry.schedule();
- renderLink(link, null);
+ showLink(null);
notice.replaceChildren(
noticeCard({
title: `Can't reach ${host.name}`,
@@ -471,7 +543,7 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") {
// 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();
- renderLink(link, { path: "connecting", rtt_ms: 0 });
+ showLink({ path: "connecting", rtt_ms: 0 });
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.
@@ -500,10 +572,11 @@ function hostScreen(host: Host, direction: "push" | "pop" = "pop") {
if (!lastTree || dropped) notice.replaceChildren();
dropped = false;
lastTree = msg.tree;
- body.replaceChildren(...renderTree(host, msg.tree, failed));
+ seen.set(host.id, { link: seen.get(host.id)?.link ?? null, tabs: tabCount(msg.tree) });
+ draw();
break;
case "link":
- renderLink(link, msg.link);
+ showLink(msg.link);
break;
case "error":
clearTimeout(slow);
@@ -564,11 +637,40 @@ const STATUS_WORD: Record = {
* to, or null for the paired machine itself. */
type Place = { key: string; name: string } | null;
-function renderTree(host: Host, tree: Tree, failed: (message: string) => void): Node[] {
+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),
+ };
+}
+
+function renderTree(host: Host, tree: Tree, query = ""): 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 out: Node[] = [];
const remotes = tree.remotes ?? [];
- out.push(...tree.workspaces.map((ws) => workspaceGroup(host, null, ws, failed)));
+ out.push(...tree.workspaces.map((ws) => workspaceGroup(host, null, ws)));
if (tree.workspaces.length === 0) {
out.push(
h(
@@ -625,7 +727,7 @@ function renderTree(host: Host, tree: Tree, failed: (message: string) => void):
!remote.pending &&
remote.workspaces.length === 0 &&
h("p", { class: "remote-note" }, `No workspaces on ${remote.name}.`),
- ...remote.workspaces.map((ws) => workspaceGroup(host, remote, ws, failed)),
+ ...remote.workspaces.map((ws) => workspaceGroup(host, remote, ws)),
),
);
}
@@ -633,41 +735,137 @@ function renderTree(host: Host, tree: Tree, failed: (message: string) => void):
}
/** 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. */
-function workspaceGroup(host: Host, place: Place, ws: WorkspaceView, failed: (message: string) => void) {
+ * 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) {
+ const rows = ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane)));
return h(
"section",
{ class: "group" },
h(
"div",
{ class: "group-head" },
- h("h2", { class: "group-title" }, workspaceName(ws.name)),
- newTabButton(host, place, ws, failed),
- ),
- h(
- "div",
- { class: "card" },
- ...ws.tabs.flatMap((tab) => tab.panes.map((pane) => paneRow(host, place, tab, pane))),
+ 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."),
);
}
-/** Starts a shell in a new tab at the end of a workspace, in the directory
- * its last tab is in, sized to this screen, and opens it. */
-function newTabButton(host: Host, place: Place, ws: WorkspaceView, failed: (message: string) => void) {
- const button = h("button", { class: "head-action" }, ico("plus"), "New tab");
- button.onclick = async () => {
- button.disabled = true;
+/** 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) {
+ 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")));
+ let target = 0;
+
+ 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 sheet = h(
+ "div",
+ { class: "sheet", role: "dialog", ariaLabel: "New tab" },
+ h("span", { class: "sheet-grip" }),
+ h(
+ "div",
+ { class: "sheet-head" },
+ h("h2", { class: "sheet-title" }, "New tab"),
+ h("button", { class: "round", ariaLabel: "Close", onclick: () => close() }, ico("close")),
+ ),
+ 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,
+ );
+ 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);
+ };
+ 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());
- terminalScreen(host, place, { id: created.pane_id, title: "shell", cwd }, "New tab");
+ scrim.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));
- button.disabled = false;
+ open.disabled = false;
}
};
- return button;
+ document.body.append(scrim);
}
/** The grid that fills this screen at the readable size: what a tab started
@@ -675,8 +873,9 @@ function newTabButton(host: Host, place: Place, ws: WorkspaceView, failed: (mess
function phoneGrid() {
const cellW = READABLE_PX * CELL_EM;
const cellH = READABLE_PX * 1.18;
- // The terminal screen's bar and key bar, and the xterm padding.
- const chrome = 48 + 56 + 16;
+ // 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)),
@@ -720,6 +919,7 @@ function paneRow(host: Host, place: Place, tab: TabView, pane: PaneView) {
),
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"),
);
}
@@ -731,17 +931,13 @@ function avatar(agent: AgentView | null | undefined, cls = "avatar") {
const el = h("span", { class: cls });
if (agent) {
const look = agentLook(agent.kind);
- el.style.setProperty("--field", look.field);
- el.style.setProperty("--mark-ink", look.ink);
- el.classList.add("agent");
el.title = `${look.name}: ${STATUS_WORD[agent.status]}`;
if (look.mark) {
const mark = h("span", { class: "mark" });
mark.style.setProperty("--mark", `url("${look.mark}")`);
el.append(mark);
- } else el.append(ico("terminal"));
- if (agent.status !== "idle") el.append(h("span", { class: `badge ${agent.status}` }));
- } else el.append(ico("terminal"));
+ } else el.append(h("span", { class: "glyph" }, look.name.slice(0, 2)));
+ } else el.append(h("span", { class: "glyph" }, ">_"));
return el;
}
@@ -786,8 +982,9 @@ interface MenuItem {
run: () => void;
}
-/** A trailing ⋯ button with a small menu that drops from it. */
-function menuButton(items: MenuItem[]) {
+/** 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) => {
@@ -797,8 +994,10 @@ function menuButton(items: MenuItem[]) {
list.hidden = true;
document.removeEventListener("pointerdown", outside, true);
};
- for (const item of items)
- list.append(
+ const fill = () => {
+ list.replaceChildren();
+ for (const item of typeof items === "function" ? items() : items)
+ list.append(
h(
"button",
{
@@ -809,13 +1008,15 @@ function menuButton(items: MenuItem[]) {
item.run();
},
},
- h("span", {}, item.label),
- ico(item.icon),
- ),
- );
- const button = h("button", { class: "nav-icon", ariaLabel: "More" }, ico("more"));
+ 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);
};
@@ -827,8 +1028,9 @@ function menuButton(items: MenuItem[]) {
// 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 presets (src/ui/presets.rs), so a pane reads
-// the same on the phone as in the window it lives in.
+// 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"],
@@ -841,8 +1043,8 @@ function terminalTheme(): ITheme {
const dark = darkScheme.matches;
const ansi = dark ? ANSI.dark : ANSI.light;
const theme: Record = dark
- ? { background: "#191b20", foreground: "#e2e5eb", cursor: "#78a8f5", cursorAccent: "#191b20", selectionBackground: "#78a8f555" }
- : { background: "#ffffff", foreground: "#0f1419", cursor: "#1f6bf0", cursorAccent: "#ffffff", selectionBackground: "#1f6bf033" };
+ ? { 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";
@@ -857,7 +1059,8 @@ function terminalTheme(): 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. */
+ * `text` is what it shows when that is not its label; `flex` its share of
+ * the row. */
type Key = {
label: string;
seq: string;
@@ -865,35 +1068,47 @@ type Key = {
icon?: keyof typeof icon;
latch?: boolean;
paste?: boolean;
+ flex?: number;
};
-const KEYS: Key[] = [
- { label: "esc", seq: "\x1b" },
- // 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: "tab", seq: "\t" },
- // Claude Code's mode switch, among others.
- { label: "Shift Tab", text: "⇧tab", seq: "\x1b[Z" },
- { label: "ctrl", seq: "", latch: true },
- { label: "^C", seq: "\x03" },
- { label: "Left", seq: "\x1b[D", icon: "left" },
- { label: "Right", seq: "\x1b[C", icon: "right" },
- { label: "Up", seq: "\x1b[A", icon: "up" },
- { label: "Down", seq: "\x1b[B", icon: "down" },
- // 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 },
- { label: "|", seq: "|" },
- { label: "/", seq: "/" },
- { label: "~", seq: "~" },
- { label: "-", seq: "-" },
+/** The key row, a page at a time: what an agent and a shell need most first,
+ * a swipe away the rest. */
+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: "_" },
+ { label: "Home", seq: "\x1b[H", flex: 1.4 },
+ { label: "End", seq: "\x1b[F", flex: 1.4 },
+ ],
];
/** Hack's advance width, in ems — what the fit divides by. */
@@ -905,7 +1120,9 @@ const READABLE_PX = 11;
* across leaving the pane, and across a send that did not get through. */
const drafts = new Map();
-function terminalScreen(host: Host, place: Place, pane: PaneView, title: 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.
@@ -916,25 +1133,34 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
place && path ? `${place.name}:${shortPath(path)}` : shortPath(path);
const cwd = h("span", { class: "term-cwd" }, where(pane.cwd));
const sub = h("span", { class: "term-sub" }, state, cwd);
- const zoom = h("button", { class: "nav-icon", ariaLabel: "Fit the whole width" }, ico("fit"));
- const select = h("button", { class: "nav-icon", ariaLabel: "Select text" }, ico("copy"));
- const take = h("button", { class: "nav-icon", ariaLabel: "Use this phone's screen size" }, ico("phone"));
- let head = avatar(pane.agent, "avatar small");
+ // 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) },
+ ...(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 bar = h(
"header",
- { class: "nav term-nav" },
- h(
- "div",
- { class: "nav-lead" },
- h("button", { class: "nav-back", ariaLabel: `Back to ${host.name}`, onclick: () => hostScreen(host) }, ico("back")),
- ),
- h(
- "div",
- { class: "term-head" },
- head,
- h("div", { class: "term-titles" }, h("span", { class: "term-title" }, title), sub),
- ),
- h("div", { class: "nav-trail" }, select, take, zoom),
+ { class: "term-nav" },
+ h("button", { class: "round", ariaLabel: `Back to ${host.name}`, onclick: () => hostScreen(host) }, 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" });
@@ -949,12 +1175,10 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
h("div", { class: "term-copy-bar" }, h("span", {}, "Select text to copy"), copyDone),
copyText,
);
- const keys = h("div", { class: "keys" });
- const keyboard = h("button", { class: "key key-kbd", ariaLabel: "Show or hide the keyboard" }, ico("keyboard"));
- const composeKey = h("button", { class: "key key-kbd", ariaLabel: "Write a message" }, ico("compose"));
- const keybar = h("div", { class: "keybar" }, keys, composeKey, keyboard);
+ const pages = h("div", { class: "key-pages" });
+ const dots = h("div", { class: "key-dots", ariaHidden: "true" });
- // The compose box: a real text field, so the phone's own keyboard works
+ // 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}`;
@@ -962,20 +1186,22 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
class: "compose-input",
rows: 1,
value: drafts.get(draftKey) ?? "",
- placeholder: pane.agent ? `Reply to ${agentLook(pane.agent.kind).name}` : "Type a command",
+ placeholder: pane.agent ? `Message ${agentLook(pane.agent.kind).name}…` : "Type a command…",
enterKeyHint: "send",
ariaLabel: "Message",
});
- const sendKey = h("button", { class: "compose-send", ariaLabel: "Send" }, ico("send"));
- const compose = h("div", { class: "compose", hidden: true }, field, sendKey);
+ 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"));
+ const compose = h("div", { class: "compose" }, field, sendKey, keyboard);
const view = h(
"div",
{ class: "screen term-screen" },
bar,
h("div", { class: "term-wrap" }, screenEl, copyView, banner),
- compose,
- keybar,
+ h("div", { class: "term-dock" }, pages, dots, compose),
);
const term = new Terminal({
@@ -995,7 +1221,8 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
let ctrl = false;
let cols = 80;
let alive = true;
- let composing = false;
+ // 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
@@ -1052,29 +1279,40 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
return; // Declined at the system's paste prompt.
}
if (!text) return;
- if (composing) {
+ if (document.activeElement === field) {
field.setRangeText(text, field.selectionStart, field.selectionEnd, "end");
edited();
} else term.paste(text);
};
- for (const k of KEYS) {
- if (k.paste && !canPaste) continue;
- const key = h("button", { class: "key", ariaLabel: k.label }, k.icon ? ico(k.icon) : (k.text ?? k.label));
- if (k.latch) ctrlKey = key;
- // Keep focus where it is — the terminal or the compose box — so the
- // soft keyboard stays up.
- 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);
- if (!composing) term.focus();
- };
- keys.append(key);
+ for (const page of KEY_PAGES) {
+ 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.
@@ -1085,6 +1323,9 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
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;
grow();
};
field.addEventListener("input", edited);
@@ -1110,29 +1351,9 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
sendKey.onpointerdown = (e) => e.preventDefault();
sendKey.onclick = () => void submit();
- const setComposing = (on: boolean, focus: boolean) => {
- composing = on;
- compose.hidden = !on;
- composeKey.classList.toggle("on", on);
- composeKey.ariaLabel = on ? "Type into the terminal" : "Write a message";
- if (!on) {
- field.blur();
- if (focus) term.focus();
- return;
- }
- grow();
- if (focus) field.focus();
- };
- composeKey.onpointerdown = (e) => e.preventDefault();
- composeKey.onclick = () => setComposing(!composing, true);
- // An agent's pane opens on the box, keyboard down: read first, then reply.
- if (pane.agent) setComposing(true, false);
keyboard.onpointerdown = (e) => e.preventDefault();
keyboard.onclick = () => {
- const active = document.activeElement;
- if (screenEl.contains(active)) term.blur();
- else if (active === field) field.blur();
- else if (composing) field.focus();
+ if (screenEl.contains(document.activeElement)) term.blur();
else term.focus();
};
@@ -1149,18 +1370,15 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
// Taken over, the pane is exactly the phone's width at the readable
// size: nothing to shrink, nothing to pan.
if (leased) {
- zoom.hidden = true;
+ cramped = false;
term.options.fontSize = READABLE_PX;
screenEl.classList.remove("panning");
return;
}
const fitted = fittedSize();
- const cramped = fitted < READABLE_PX;
- zoom.hidden = !cramped;
+ cramped = fitted < READABLE_PX;
term.options.fontSize = cramped && readable ? READABLE_PX : Math.max(4, fitted);
screenEl.classList.toggle("panning", cramped && readable);
- zoom.replaceChildren(ico(readable ? "fit" : "zoom"));
- zoom.ariaLabel = readable ? "Fit the whole width" : "Make the text readable";
follow();
// The new font size is laid out on the next frame.
requestAnimationFrame(pinScrollbar);
@@ -1189,7 +1407,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
if (x < screenEl.scrollLeft + cell * 4 || x > screenEl.scrollLeft + view - cell * 4)
screenEl.scrollLeft = Math.max(0, x - view / 2);
};
- zoom.onclick = () => {
+ const toggleZoom = () => {
readable = !readable;
fit();
};
@@ -1221,13 +1439,10 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
sent = key;
api.paneLease(handle, grid).catch(() => {});
};
- const showTake = () => {
- take.classList.toggle("on", wanted);
- take.ariaLabel = wanted ? "Give the pane back to the desktop's size" : "Use this phone's screen size";
- };
- take.onclick = () => {
+ // Taken over is said under the title, beside Live.
+ const liveLabel = () => (leased ? "Live · phone size" : "Live");
+ const toggleTake = () => {
wanted = !wanted;
- showTake();
if (wanted) {
sent = "";
askLease();
@@ -1263,12 +1478,12 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
label: "Take over again",
run: () => {
banner.replaceChildren();
- take.click();
+ toggleTake();
},
});
}
- showTake();
}
+ if (live) setState("live", liveLabel());
fit();
};
term.onCursorMove(follow);
@@ -1290,7 +1505,6 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
};
const selecting = (on: boolean) => {
copyView.hidden = !on;
- select.classList.toggle("on", on);
if (!on) {
getSelection()?.removeAllRanges();
copyText.textContent = "";
@@ -1301,7 +1515,6 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
copyText.textContent = bufferText();
copyText.scrollTop = copyText.scrollHeight;
};
- select.onclick = () => selecting(copyView.hidden);
copyDone.onclick = () => selecting(false);
const retheme = () => {
@@ -1361,12 +1574,11 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
term.resize(event.cols, event.rows);
fit();
break;
- case "agent": {
- const next = avatar(event.agent, "avatar small");
- head.replaceWith(next);
- head = next;
+ case "agent":
+ field.placeholder = event.agent
+ ? `Message ${agentLook(event.agent.kind).name}…`
+ : "Type a command…";
break;
- }
case "cwd":
cwd.textContent = where(event.path);
break;
@@ -1402,8 +1614,13 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
askLease();
retry.reset();
banner.replaceChildren();
- setState("live", "Live");
- if (!composing) term.focus();
+ 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));
}
@@ -1439,8 +1656,11 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string)
document.fonts.load("12px Hack").finally(() => {
if (!alive) return;
term.open(screenEl);
+ // The keyboard button shows when the terminal has the keyboard.
+ term.textarea?.addEventListener("focus", () => keyboard.classList.add("on"));
+ term.textarea?.addEventListener("blur", () => keyboard.classList.remove("on"));
fit();
- grow();
+ edited();
open();
});
});
diff --git a/mobile/src/style.css b/mobile/src/style.css
index 56984de4..0aadbad3 100644
--- a/mobile/src/style.css
+++ b/mobile/src/style.css
@@ -1,5 +1,6 @@
-/* tty7 mobile. The desktop's Light and Dark presets, following the system;
- grouped lists on a tinted canvas; Hack wherever there is code. */
+/* tty7 mobile. The desktop's neutral greys, light and dark following the
+ system; ink, not a system blue, for what is pressed; grouped cards on a warm
+ canvas; Hack wherever there is code. */
@font-face {
font-family: Hack;
@@ -16,34 +17,45 @@
}
:root {
- --canvas: #f2f3f6;
- --cell: #ffffff;
- --cell-press: #e6e8ed;
- --raised: #ffffff;
- --ink: #0f1419;
- --ink-2: #59616d;
- --ink-3: #9aa1ab;
- --hair: rgb(15 20 25 / 0.1);
- --accent: #1f6bf0;
- --accent-ink: #ffffff;
- --tint: rgb(31 107 240 / 0.1);
+ --canvas: #f3f3f1;
+ --cell: #fcfcfb;
+ --cell-press: #ecece9;
+ --raised: #fcfcfb;
+ --ink: #1c1c1e;
+ --ink-soft: rgb(0 0 0 / 0.72);
+ --ink-2: rgb(0 0 0 / 0.45);
+ --ink-3: rgb(0 0 0 / 0.3);
+ --hair: rgb(0 0 0 / 0.08);
+ --fill: rgb(0 0 0 / 0.06);
+ --fill-faint: rgb(0 0 0 / 0.035);
+ --line: rgb(0 0 0 / 0.15);
+ /* What is pressed or chosen is drawn in ink, not a system blue. */
+ --accent: var(--ink);
+ --accent-ink: #fcfcfb;
+ --tint: var(--fill);
--danger: #c43c43;
- --warn-ink: #8a5a0c;
- --ok-ink: #1f7a47;
- --term: #ffffff;
- --shadow: 0 12px 32px rgb(15 20 25 / 0.16), 0 2px 6px rgb(15 20 25 / 0.08);
+ --warn-ink: #b7791f;
+ --ok-ink: #248a3d;
+ --term: var(--cell);
+ --bar: rgb(252 252 251 / 0.82);
+ /* Floating controls: a hairline ring and a soft drop. */
+ --lift: 0 0 0 0.5px rgb(0 0 0 / 0.08), 0 8px 24px -8px rgb(0 0 0 / 0.18);
+ --shadow: 0 0 0 0.5px rgb(0 0 0 / 0.08), 0 18px 44px -12px rgb(0 0 0 / 0.28);
+ /* Running and connected. */
+ --live: #34c759;
/* The desktop's status dots (cli_agent.rs `dot_rgb`), fixed across themes. */
--working: #3b82f6;
--waiting: #f59e0b;
--done: #22c55e;
- --ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", Roboto, "Segoe UI", system-ui, sans-serif;
- --mono: Hack, Menlo, ui-monospace, monospace;
+ --ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", Roboto, "Segoe UI", system-ui, sans-serif;
+ --mono: Hack, "SF Mono", Menlo, ui-monospace, monospace;
--ease: cubic-bezier(0.32, 0.72, 0, 1);
color-scheme: light dark;
font: 17px/1.35 var(--ui);
+ letter-spacing: -0.005em;
color: var(--ink);
background: var(--canvas);
-webkit-text-size-adjust: 100%;
@@ -52,22 +64,25 @@
@media (prefers-color-scheme: dark) {
:root {
- --canvas: #0f1013;
- --cell: #191b20;
- --cell-press: #24272e;
- --raised: #22252b;
- --ink: #e2e5eb;
- --ink-2: #9ba2ae;
- --ink-3: #5f6672;
- --hair: rgb(226 229 235 / 0.09);
- --accent: #78a8f5;
- --accent-ink: #0f1419;
- --tint: rgb(120 168 245 / 0.14);
+ --canvas: #0f0f10;
+ --cell: #1e1e20;
+ --cell-press: #29292c;
+ --raised: #242427;
+ --ink: #ececed;
+ --ink-soft: rgb(255 255 255 / 0.78);
+ --ink-2: rgb(255 255 255 / 0.5);
+ --ink-3: rgb(255 255 255 / 0.3);
+ --hair: rgb(255 255 255 / 0.1);
+ --fill: rgb(255 255 255 / 0.08);
+ --fill-faint: rgb(255 255 255 / 0.045);
+ --line: rgb(255 255 255 / 0.16);
+ --accent-ink: #18181a;
--danger: #f07878;
- --warn-ink: #e3bd5d;
- --ok-ink: #78bd95;
- --term: #191b20;
- --shadow: 0 12px 32px rgb(0 0 0 / 0.5), 0 2px 6px rgb(0 0 0 / 0.3);
+ --warn-ink: #e0a345;
+ --ok-ink: #5fd07a;
+ --bar: rgb(30 30 32 / 0.82);
+ --lift: 0 0 0 0.5px rgb(255 255 255 / 0.08), 0 8px 24px -8px rgb(0 0 0 / 0.6);
+ --shadow: 0 0 0 0.5px rgb(255 255 255 / 0.08), 0 18px 44px -12px rgb(0 0 0 / 0.7);
}
}
@@ -153,6 +168,7 @@ code {
/* Screens and the bar */
.screen {
+ position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -165,9 +181,9 @@ code {
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
- height: calc(48px + env(safe-area-inset-top));
- padding: env(safe-area-inset-top) max(8px, env(safe-area-inset-right)) 0
- max(8px, env(safe-area-inset-left));
+ height: calc(44px + env(safe-area-inset-top));
+ padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0
+ max(16px, env(safe-area-inset-left));
background: var(--canvas);
box-shadow: 0 1px 0 transparent;
transition:
@@ -178,7 +194,9 @@ code {
}
.nav.folded {
- background: color-mix(in srgb, var(--cell) 78%, var(--canvas));
+ background: var(--bar);
+ -webkit-backdrop-filter: blur(20px);
+ backdrop-filter: blur(20px);
box-shadow: 0 0.5px 0 var(--hair);
}
@@ -199,7 +217,7 @@ code {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- max-width: 55vw;
+ max-width: 50vw;
opacity: 0;
transform: translateY(4px);
transition:
@@ -212,15 +230,18 @@ code {
transform: none;
}
+/* Back is a floating pill that names where it goes. */
.nav-back {
display: flex;
align-items: center;
- gap: 1px;
- height: 44px;
- padding: 0 8px 0 2px;
- color: var(--accent);
- font-size: 17px;
- border-radius: 10px;
+ gap: 4px;
+ height: 38px;
+ padding: 0 14px 0 8px;
+ border-radius: 19px;
+ background: var(--cell);
+ box-shadow: var(--lift);
+ color: var(--ink);
+ font-size: 15px;
min-width: 0;
}
@@ -231,22 +252,47 @@ code {
}
.nav-back .icon {
- width: 26px;
- height: 26px;
+ width: 20px;
+ height: 20px;
}
.nav-back:active,
-.nav-icon:active {
- opacity: 0.45;
+.nav-icon:active,
+.round:active {
+ opacity: 0.55;
}
.nav-icon {
display: grid;
place-items: center;
- width: 44px;
- height: 44px;
- border-radius: 22px;
- color: var(--accent);
+ width: 38px;
+ height: 38px;
+ border-radius: 19px;
+ background: var(--cell);
+ box-shadow: var(--lift);
+ color: var(--ink);
+}
+
+.nav-icon .icon {
+ width: 22px;
+ height: 22px;
+}
+
+/* A round control on a surface of its own: the terminal's bar, a sheet. */
+.round {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ background: var(--fill);
+ color: var(--ink);
+}
+
+.round .icon {
+ width: 20px;
+ height: 20px;
}
.scroll {
@@ -259,7 +305,22 @@ code {
}
.title-block {
- padding: 2px 20px 18px;
+ padding: 14px 24px 22px;
+}
+
+/* A top-level screen has nothing in its bar until the title folds into it:
+ the title starts 34pt under the status bar instead. */
+.screen.root .nav {
+ height: calc(34px + env(safe-area-inset-top));
+}
+
+.screen.root .title-block {
+ padding-top: 0;
+}
+
+/* Clear of the floating bar at the bottom. */
+.screen.docked .scroll {
+ padding-bottom: calc(104px + env(safe-area-inset-bottom));
}
.large-title {
@@ -274,44 +335,27 @@ code {
/* Grouped lists */
.group {
- margin: 0 16px 26px;
+ margin: 0 20px 22px;
}
.group-head {
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 12px;
+ padding: 0 4px;
margin-bottom: 8px;
}
.group-head .group-title {
- padding-bottom: 0;
+ padding: 0;
+ min-width: 0;
}
-.head-action {
- display: inline-flex;
- align-items: center;
- gap: 3px;
- height: 30px;
- margin-right: -6px;
- padding: 0 8px 0 5px;
- border-radius: 10px;
- color: var(--accent);
- font-size: 14px;
- font-weight: 600;
-}
-
-.head-action .icon {
- width: 19px;
- height: 19px;
-}
-
-.head-action:active {
- opacity: 0.45;
-}
-
-.head-action:disabled {
- opacity: 0.35;
+.group-count {
+ font-size: 13px;
+ color: var(--ink-2);
+ font-variant-numeric: tabular-nums;
}
.group-title {
@@ -320,21 +364,29 @@ code {
gap: 8px;
padding: 0 4px 8px;
font-size: 13px;
- font-weight: 600;
+ font-weight: 500;
color: var(--ink-2);
overflow-wrap: anywhere;
}
-.group-note {
+.group-note,
+.group-empty {
padding: 8px 4px 0;
font-size: 13px;
line-height: 1.45;
color: var(--ink-2);
}
+.search-empty {
+ margin: 8px 24px;
+ font-size: 15px;
+ color: var(--ink-2);
+ overflow-wrap: anywhere;
+}
+
.card {
background: var(--cell);
- border-radius: 16px;
+ border-radius: 22px;
overflow: hidden;
}
@@ -392,20 +444,28 @@ code {
position: relative;
display: flex;
align-items: center;
- gap: 12px;
+ gap: 14px;
width: 100%;
- min-height: 62px;
- padding: 10px 12px 10px 14px;
+ min-height: 64px;
+ padding: 10px 16px;
text-align: left;
transition: background 0.35s;
}
+.row.machine {
+ min-height: 68px;
+}
+
+.row.dim > :not(.row-chevron) {
+ opacity: 0.55;
+}
+
.row + .row::before {
content: "";
position: absolute;
top: 0;
right: 0;
- left: 64px;
+ left: 0;
height: 0.5px;
background: var(--hair);
}
@@ -436,13 +496,19 @@ code {
}
.row-sub {
- font-size: 14px;
+ font-size: 13px;
color: var(--ink-2);
}
+.row-sub.link {
+ margin: 0;
+ gap: 6px;
+ font-size: 13px;
+}
+
.row-chevron {
- width: 18px;
- height: 18px;
+ width: 16px;
+ height: 16px;
color: var(--ink-3);
}
@@ -451,7 +517,7 @@ code {
}
.status-word.working {
- color: var(--accent);
+ color: var(--ink-soft);
}
.status-word.waiting {
@@ -462,22 +528,41 @@ code {
color: var(--ok-ink);
}
-/* Machine tiles and pane avatars */
+/* On the right of a pane's row: running, or waiting on you (hollow, so it
+ differs in shape and not only in hue). */
+.status-dot {
+ flex: none;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+}
+
+.status-dot.working {
+ background: var(--live);
+}
+
+.status-dot.waiting {
+ width: 9px;
+ height: 9px;
+ box-shadow: inset 0 0 0 2px var(--waiting);
+}
+
+/* Machine tiles and pane avatars: quiet greys, the agent's mark in ink. */
.tile {
display: grid;
place-items: center;
flex: none;
- width: 38px;
- height: 38px;
- border-radius: 11px;
- background: var(--tint);
- color: var(--accent);
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: var(--fill);
+ color: var(--ink-soft);
}
.tile .icon {
- width: 22px;
- height: 22px;
+ width: 20px;
+ height: 20px;
}
.tile.large {
@@ -496,82 +581,28 @@ code {
display: grid;
place-items: center;
flex: none;
- width: 38px;
- height: 38px;
+ width: 32px;
+ height: 32px;
border-radius: 50%;
- background: color-mix(in srgb, var(--ink) 7%, var(--cell));
- color: var(--ink-2);
-}
-
-.avatar .icon {
- width: 22px;
- height: 22px;
-}
-
-.avatar.agent {
- background: var(--field);
- color: var(--mark-ink);
- box-shadow: inset 0 0 0 0.5px var(--hair);
+ background: var(--fill);
+ color: var(--ink-soft);
}
.avatar .mark {
- width: 21px;
- height: 21px;
- background: var(--mark-ink);
+ width: 17px;
+ height: 17px;
+ background: var(--ink-soft);
-webkit-mask: var(--mark) center / contain no-repeat;
mask: var(--mark) center / contain no-repeat;
}
-.avatar.small {
- width: 30px;
- height: 30px;
+.avatar .glyph {
+ font: 600 12px var(--mono);
+ letter-spacing: -0.06em;
}
-.avatar.small .icon {
- width: 18px;
- height: 18px;
-}
-
-.avatar.small .mark {
- width: 17px;
- height: 17px;
-}
-
-/* The status badge: a dot ringed in the cell's colour, sitting on the avatar
- the way it does on a desktop tab. Waiting has a hole in it. */
-.badge {
- position: absolute;
- right: -2px;
- bottom: -2px;
- width: 14px;
- height: 14px;
- border-radius: 50%;
- border: 2.5px solid var(--cell);
-}
-
-.term-nav .badge {
- border-color: color-mix(in srgb, var(--cell) 78%, var(--canvas));
- width: 12px;
- height: 12px;
-}
-
-.badge.working {
- background: var(--working);
- animation: blink 1.4s steps(1, end) infinite;
-}
-
-.badge.waiting {
- background: radial-gradient(circle, #fff 0 27%, var(--waiting) 31%);
-}
-
-.badge.done {
- background: var(--done);
-}
-
-@keyframes blink {
- 50% {
- background: color-mix(in srgb, var(--working) 40%, var(--cell));
- }
+.row.asleep > .avatar {
+ opacity: 0.45;
}
/* Loading */
@@ -611,22 +642,22 @@ code {
align-items: center;
gap: 7px;
margin-top: 6px;
- font-size: 15px;
+ font-size: 14px;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.link-dot {
flex: none;
- width: 8px;
- height: 8px;
+ width: 6px;
+ height: 6px;
border-radius: 50%;
background: var(--ink-3);
}
.direct .link-dot,
.live .link-dot {
- background: var(--done);
+ background: var(--live);
}
.relay .link-dot {
@@ -684,9 +715,9 @@ code {
align-items: center;
justify-content: center;
gap: 6px;
- height: 50px;
- padding: 0 26px;
- border-radius: 14px;
+ height: 54px;
+ padding: 0 28px;
+ border-radius: 27px;
font-size: 17px;
font-weight: 600;
transition:
@@ -711,19 +742,19 @@ code {
.button.primary:disabled {
opacity: 1;
- background: var(--hair);
- color: var(--ink-2);
+ background: var(--fill);
+ color: var(--ink-3);
}
.button.tinted {
- background: var(--tint);
- color: var(--accent);
+ background: var(--fill);
+ color: var(--ink);
}
.button.small {
height: 34px;
padding: 0 14px;
- border-radius: 10px;
+ border-radius: 17px;
font-size: 15px;
}
@@ -979,9 +1010,9 @@ html.scanning #app {
top: 46px;
right: 2px;
z-index: 10;
- min-width: 232px;
+ min-width: 240px;
padding: 0;
- border-radius: 14px;
+ border-radius: 16px;
background: var(--raised);
box-shadow: var(--shadow);
overflow: hidden;
@@ -1032,33 +1063,35 @@ html.scanning #app {
background: var(--term);
}
+/* Back, the title with its state, and ⋯: nothing else. */
.term-nav {
- grid-template-columns: auto 1fr auto;
- background: color-mix(in srgb, var(--cell) 78%, var(--canvas));
- box-shadow: 0 0.5px 0 var(--hair);
-}
-
-.term-nav .nav-back {
- padding-right: 4px;
-}
-
-.term-head {
+ flex: none;
display: flex;
align-items: center;
gap: 10px;
- min-width: 0;
+ height: calc(56px + env(safe-area-inset-top));
+ padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0
+ max(16px, env(safe-area-inset-left));
+ background: var(--term);
+ box-shadow: 0 0.5px 0 var(--hair);
+ position: relative;
+ z-index: 2;
}
.term-titles {
+ flex: 1;
display: flex;
flex-direction: column;
+ align-items: center;
+ gap: 1px;
min-width: 0;
}
.term-title {
- font-size: 16px;
+ max-width: 100%;
+ font-size: 15px;
font-weight: 600;
- line-height: 1.2;
+ line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1067,9 +1100,9 @@ html.scanning #app {
.term-sub {
display: flex;
align-items: center;
- gap: 8px;
+ max-width: 100%;
min-width: 0;
- font-size: 12.5px;
+ font-size: 12px;
line-height: 1.35;
color: var(--ink-2);
white-space: nowrap;
@@ -1080,7 +1113,6 @@ html.scanning #app {
align-items: center;
gap: 5px;
flex: none;
- font-weight: 600;
}
.term-state.offline {
@@ -1088,19 +1120,19 @@ html.scanning #app {
}
.term-state .link-dot {
- width: 7px;
- height: 7px;
+ width: 5px;
+ height: 5px;
}
.term-cwd {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
- font: 11.5px var(--mono);
}
-.term-cwd:empty {
- display: none;
+.term-cwd:not(:empty)::before {
+ content: " · ";
+ white-space: pre;
}
.term-wrap {
@@ -1183,9 +1215,6 @@ html.scanning #app {
-webkit-touch-callout: default;
}
-.nav-icon.on {
- background: var(--tint);
-}
.term-banner-slot {
position: absolute;
@@ -1215,113 +1244,63 @@ html.scanning #app {
overflow-wrap: anywhere;
}
-.keybar {
+/* The dock under the terminal: a row of keys a page at a time, then the
+ message box and the keyboard. */
+.term-dock {
flex: none;
display: flex;
- align-items: center;
+ flex-direction: column;
gap: 8px;
- padding: 8px max(8px, env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom))
- max(8px, env(safe-area-inset-left));
- background: color-mix(in srgb, var(--cell) 78%, var(--canvas));
+ padding: 10px max(12px, env(safe-area-inset-right)) calc(10px + env(safe-area-inset-bottom))
+ max(12px, env(safe-area-inset-left));
+ background: var(--canvas);
box-shadow: 0 -0.5px 0 var(--hair);
}
-.keys {
+.key-pages {
display: flex;
- flex: 1;
- gap: 5px;
- min-width: 0;
overflow-x: auto;
+ scroll-snap-type: x mandatory;
scrollbar-width: none;
- mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
+ overscroll-behavior-x: contain;
}
-.keys::-webkit-scrollbar {
+.key-pages::-webkit-scrollbar {
display: none;
}
-/* The compose box sits on the key bar, in the same surface. */
-.compose {
- flex: none;
+.key-page {
display: flex;
- align-items: flex-end;
- gap: 8px;
- padding: 8px max(10px, env(safe-area-inset-right)) 0 max(10px, env(safe-area-inset-left));
- background: color-mix(in srgb, var(--cell) 78%, var(--canvas));
- box-shadow: 0 -0.5px 0 var(--hair);
-}
-
-.compose:not([hidden]) + .keybar {
- box-shadow: none;
-}
-
-.compose-input {
- flex: 1;
- min-width: 0;
- max-height: 132px;
- margin: 0;
- padding: 9px 14px;
- border: 0;
- outline: 0;
- border-radius: 19px;
- resize: none;
- background: var(--cell);
- box-shadow: inset 0 0 0 0.5px var(--hair);
- color: var(--ink);
- /* 16px or more, or iOS zooms the page when the field takes focus. */
- font: 16px/1.3 var(--ui);
- -webkit-user-select: text;
- user-select: text;
-}
-
-.compose-input::placeholder {
- color: var(--ink-3);
-}
-
-.compose-send {
- display: grid;
- place-items: center;
- flex: none;
- width: 38px;
- height: 38px;
- border-radius: 50%;
- background: var(--accent);
- color: var(--accent-ink);
- transition: transform 0.15s var(--ease);
-}
-
-.compose-send .icon {
- width: 20px;
- height: 20px;
-}
-
-.compose-send:active {
- transform: scale(0.92);
- transition: none;
+ gap: 6px;
+ flex: 0 0 100%;
+ scroll-snap-align: start;
+ /* A page's last key clear of the next page's first. */
+ padding-right: 1px;
}
.key {
display: grid;
place-items: center;
- flex: none;
- min-width: 41px;
- height: 40px;
- padding: 0 9px;
+ flex: 1;
+ min-width: 0;
+ height: 42px;
+ padding: 0 2px;
border-radius: 10px;
background: var(--cell);
box-shadow:
- 0 1px 1.5px rgb(0 0 0 / 0.18),
- inset 0 0 0 0.5px var(--hair);
+ 0 0 0 0.5px var(--hair),
+ 0 1px 0 var(--hair);
font: 14px var(--mono);
color: var(--ink);
+ white-space: nowrap;
transition:
background 0.25s,
transform 0.15s var(--ease);
}
.key .icon {
- width: 18px;
- height: 18px;
+ width: 17px;
+ height: 17px;
}
.key:active {
@@ -1331,19 +1310,328 @@ html.scanning #app {
}
.key.on {
- background: var(--accent);
+ background: var(--ink);
color: var(--accent-ink);
}
-.key-kbd {
- color: var(--accent);
+.key-dots {
+ display: flex;
+ justify-content: center;
+ gap: 5px;
+ height: 4px;
}
-.key-kbd .icon {
+.key-dots span {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--ink-3);
+ transition: background 0.2s;
+}
+
+.key-dots span.on {
+ background: var(--ink);
+}
+
+.compose {
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+}
+
+.compose-input {
+ flex: 1;
+ min-width: 0;
+ min-height: 44px;
+ max-height: 132px;
+ margin: 0;
+ padding: 11px 16px;
+ border: 0;
+ outline: 0;
+ border-radius: 22px;
+ resize: none;
+ background: var(--cell);
+ box-shadow: 0 0 0 0.5px var(--hair);
+ color: var(--ink);
+ /* 16px or more, or iOS zooms the page when the field takes focus. */
+ font: 16px/1.35 var(--ui);
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+.compose-input::placeholder {
+ color: var(--ink-2);
+}
+
+.compose .round {
+ width: 44px;
+ height: 44px;
+}
+
+.compose .round .icon {
width: 22px;
height: 22px;
}
+.compose .round.on {
+ background: var(--ink);
+ color: var(--accent-ink);
+}
+
+.round.send {
+ background: var(--ink);
+ color: var(--accent-ink);
+ transition: transform 0.15s var(--ease);
+}
+
+.round.send:active {
+ transform: scale(0.92);
+ opacity: 1;
+ transition: none;
+}
+
+/* ------------------------------------------------------------------------ */
+/* The floating bar: search and the one "+", within the thumb's reach. */
+
+.float-bar {
+ position: absolute;
+ z-index: 3;
+ left: max(16px, env(safe-area-inset-left));
+ right: max(16px, env(safe-area-inset-right));
+ bottom: calc(12px + env(safe-area-inset-bottom));
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.search {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 48px;
+ padding: 0 18px;
+ border-radius: 24px;
+ background: var(--bar);
+ -webkit-backdrop-filter: blur(20px);
+ backdrop-filter: blur(20px);
+ box-shadow: var(--lift);
+ color: var(--ink-2);
+}
+
+.search .icon {
+ width: 17px;
+ height: 17px;
+}
+
+.search-input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ border: 0;
+ outline: 0;
+ padding: 0;
+ background: none;
+ color: var(--ink);
+ font: 16px var(--ui);
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+.search-input::placeholder {
+ color: var(--ink-2);
+}
+
+.search-input::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+}
+
+.fab {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 48px;
+ height: 48px;
+ border-radius: 24px;
+ background: var(--ink);
+ color: var(--accent-ink);
+ box-shadow: var(--lift);
+ transition: transform 0.15s var(--ease);
+}
+
+.fab .icon {
+ width: 20px;
+ height: 20px;
+}
+
+.fab:active {
+ transform: scale(0.93);
+ transition: none;
+}
+
+/* ------------------------------------------------------------------------ */
+/* The New tab sheet */
+
+.scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ align-items: flex-end;
+ padding: 8px;
+ background: rgb(0 0 0 / 0.28);
+ animation: fade-in 0.22s ease-out;
+}
+
+.scrim.leaving {
+ animation: fade-out 0.22s ease-in forwards;
+}
+
+.sheet {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ width: 100%;
+ max-height: calc(100% - env(safe-area-inset-top) - 24px);
+ padding: 10px 20px calc(20px + env(safe-area-inset-bottom));
+ border-radius: 44px;
+ background: var(--canvas);
+ box-shadow: var(--shadow);
+ animation: sheet-in 0.36s var(--ease);
+}
+
+.scrim.leaving .sheet {
+ animation: sheet-out 0.22s ease-in forwards;
+}
+
+.sheet-grip {
+ align-self: center;
+ width: 36px;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--line);
+}
+
+.sheet-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 4px;
+}
+
+.sheet-title {
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+}
+
+.sheet-head .round {
+ width: 32px;
+ height: 32px;
+ color: var(--ink-2);
+}
+
+.sheet-head .round .icon {
+ width: 16px;
+ height: 16px;
+}
+
+.sheet-body {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.sheet-group .group-title {
+ padding: 0 4px 8px;
+}
+
+.agent-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.agent-choice {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ height: 84px;
+ border-radius: 18px;
+ background: var(--cell);
+ box-shadow: 0 0 0 0.5px var(--hair);
+ font-size: 14px;
+ transition: box-shadow 0.2s;
+}
+
+.agent-choice.on {
+ box-shadow: 0 0 0 1.5px var(--ink);
+ font-weight: 600;
+}
+
+.row.choice {
+ min-height: 52px;
+ gap: 12px;
+}
+
+.row.choice .row-title {
+ flex: 1;
+ min-width: 0;
+ font-size: 16px;
+ font-weight: 400;
+}
+
+.row-meta {
+ flex: none;
+ max-width: 45%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 13px;
+ color: var(--ink-2);
+}
+
+.choice-check {
+ flex: none;
+ width: 16px;
+ height: 16px;
+}
+
+.sheet-open {
+ flex: none;
+}
+
+@keyframes fade-in {
+ from {
+ background: rgb(0 0 0 / 0);
+ }
+}
+
+@keyframes fade-out {
+ to {
+ background: rgb(0 0 0 / 0);
+ }
+}
+
+@keyframes sheet-in {
+ from {
+ transform: translateY(105%);
+ }
+}
+
+@keyframes sheet-out {
+ to {
+ transform: translateY(105%);
+ }
+}
+
/* ------------------------------------------------------------------------ */
/* Moving between screens: the incoming screen slides over the outgoing one,
which recedes a little; back reverses it. */