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. */