From b045ca0de6ac15920357a83345ad332703fc0cf3 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Tue, 29 Sep 2026 17:30:23 +0800
Subject: [PATCH] feat(mobile): settings, message history, and a tighter home
screen
- Settings, from beside the Machines title: appearance (automatic, light,
dark; the status bar and keyboard follow through the window's interface
style), terminal text size, how a pane wider than the phone first shows,
clearing the message history, and the version.
- The message box keeps what it sends on the phone. As a message is
written, past ones that match take the key row's place; with the box
empty, a History button opens them all, searchable. A line that asks for
a password is sent but not kept.
- ^R on the third key page, for the shell's own history search.
- A top-level screen's bar floats over the list, clear until the title
scrolls under it, so the large title sits just under the status bar.
Claude-Session: https://claude.ai/code/session_01HVosmYyVSH3BrsLYx3tcSS
---
mobile/index.html | 4 +-
mobile/src-tauri/src/lib.rs | 31 ++-
mobile/src/api.ts | 3 +
mobile/src/icons.ts | 4 +
mobile/src/main.ts | 391 +++++++++++++++++++++++++++++++-----
mobile/src/style.css | 165 ++++++++++++---
6 files changed, 523 insertions(+), 75 deletions(-)
diff --git a/mobile/index.html b/mobile/index.html
index 8e1b96fc..923bc526 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-tauri/src/lib.rs b/mobile/src-tauri/src/lib.rs
index f33a82a8..5863f07b 100644
--- a/mobile/src-tauri/src/lib.rs
+++ b/mobile/src-tauri/src/lib.rs
@@ -422,12 +422,41 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
hosts, pair, forget, watch, refresh, tab_new, pane_open, pane_input, pane_lease,
- pane_close
+ pane_close, appearance
])
.run(tauri::generate_context!())
.expect("error while running tty7");
}
+/// Light, dark or the system's, for what the page does not draw itself: the
+/// status bar, the keyboard, the scanner's chrome. The page follows along, as
+/// the WebView reports the window's style as `prefers-color-scheme`.
+#[tauri::command]
+fn appearance(window: tauri::WebviewWindow, style: String) {
+ #[cfg(target_os = "ios")]
+ {
+ use objc2::msg_send;
+ use objc2::runtime::AnyObject;
+ // `UIUserInterfaceStyle`: unspecified follows the system.
+ let style: isize = match style.as_str() {
+ "light" => 1,
+ "dark" => 2,
+ _ => 0,
+ };
+ let _ = window.with_webview(move |webview| unsafe {
+ let Some(wk) = (webview.inner() as *const AnyObject).as_ref() else {
+ return;
+ };
+ let ui_window: *const AnyObject = msg_send![wk, window];
+ if let Some(ui_window) = ui_window.as_ref() {
+ let _: () = msg_send![ui_window, setOverrideUserInterfaceStyle: style];
+ }
+ });
+ }
+ #[cfg(not(target_os = "ios"))]
+ let _ = (window, style);
+}
+
/// Lets the page run under the status bar and the home indicator, and leaves
/// the safe areas to it (`viewport-fit=cover` and `env(safe-area-inset-*)` in
/// the CSS). By default the WKWebView's scroll view insets its content by the
diff --git a/mobile/src/api.ts b/mobile/src/api.ts
index 1c99e9c7..b7223836 100644
--- a/mobile/src/api.ts
+++ b/mobile/src/api.ts
@@ -83,6 +83,9 @@ export const pair = (code: string, deviceName: string) =>
export const forget = (hostId: string) => invoke("forget", { hostId });
+/** Sets the style of what the page does not draw: status bar, keyboard. */
+export const appearance = (style: "system" | "light" | "dark") => invoke("appearance", { style });
+
export function watch(hostId: string, onMsg: (msg: TreeMsg) => void) {
const onEvent = new Channel();
onEvent.onmessage = onMsg;
diff --git a/mobile/src/icons.ts b/mobile/src/icons.ts
index 2f057cbd..16fb5ac8 100644
--- a/mobile/src/icons.ts
+++ b/mobile/src/icons.ts
@@ -39,6 +39,10 @@ export const icon = {
copy: stroke(``),
compose: stroke(``),
send: stroke(``, 2.4),
+ settings: stroke(
+ ``,
+ ),
+ history: stroke(``),
alert: stroke(``),
};
diff --git a/mobile/src/main.ts b/mobile/src/main.ts
index 9186affe..724f782d 100644
--- a/mobile/src/main.ts
+++ b/mobile/src/main.ts
@@ -6,6 +6,8 @@ import { WebglAddon } from "@xterm/addon-webgl";
import type { ITheme } from "@xterm/xterm";
import * as scanner from "@tauri-apps/plugin-barcode-scanner";
+import { getVersion } from "@tauri-apps/api/app";
+
import * as api from "./api";
import type {
AgentStatus,
@@ -40,6 +42,8 @@ const app = document.getElementById("app")!;
const up = height < window.innerHeight - 80;
document.documentElement.classList.toggle("keyboard", up);
app.style.height = up ? `${height}px` : "";
+ // Sheets, outside the app, stand on the keyboard too.
+ document.documentElement.style.setProperty("--keyboard", up ? `${window.innerHeight - height}px` : "0px");
window.dispatchEvent(new CustomEvent("viewport"));
};
view?.addEventListener("resize", fitView);
@@ -83,6 +87,97 @@ function errorText(e: unknown) {
return typeof e === "string" ? e : e instanceof Error ? e.message : String(e);
}
+// ---------------------------------------------------------------------------
+// Preferences, kept on the phone. Appearance applies at once; the rest is read
+// as a terminal opens.
+
+type Appearance = "system" | "light" | "dark";
+
+interface Prefs {
+ appearance: Appearance;
+ /** The terminal's font size when it is not shrunk to fit, in px. */
+ textSize: number;
+ /** How a pane wider than the phone first shows: panning, or shrunk. */
+ wide: "readable" | "fit";
+}
+
+const PREFS: Prefs = { appearance: "system", textSize: 11, wide: "readable" };
+
+const prefs: Prefs = (() => {
+ try {
+ return { ...PREFS, ...JSON.parse(remembered("prefs") ?? "{}") };
+ } catch {
+ return { ...PREFS };
+ }
+})();
+
+function setPref(key: K, value: Prefs[K]) {
+ prefs[key] = value;
+ remember("prefs", JSON.stringify(prefs));
+ if (key === "appearance") applyAppearance();
+}
+
+const systemDark = matchMedia("(prefers-color-scheme: dark)");
+const themeColor = document.querySelector('meta[name="theme-color"]');
+let shownDark: boolean | null = null;
+
+function isDark() {
+ return prefs.appearance === "system" ? systemDark.matches : prefs.appearance === "dark";
+}
+
+/** Draws the page light or dark; a change is heard as a `theme` event. */
+function paintAppearance() {
+ const dark = isDark();
+ document.documentElement.dataset.theme = dark ? "dark" : "light";
+ themeColor?.setAttribute("content", dark ? "#0f0f10" : "#f3f3f1");
+ if (dark === shownDark) return;
+ shownDark = dark;
+ window.dispatchEvent(new CustomEvent("theme"));
+}
+
+function applyAppearance() {
+ paintAppearance();
+ api.appearance(prefs.appearance).catch(() => {});
+}
+
+systemDark.addEventListener("change", paintAppearance);
+applyAppearance();
+
+// What was sent from the message box, newest first, across every machine: a
+// command typed on one is as likely on the next.
+const HISTORY_MAX = 200;
+
+function sentHistory(): string[] {
+ try {
+ const list = JSON.parse(remembered("history") ?? "[]");
+ return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string") : [];
+ } catch {
+ return [];
+ }
+}
+
+function keepSent(text: string) {
+ const t = text.trim();
+ if (!t) return;
+ remember("history", JSON.stringify([t, ...sentHistory().filter((x) => x !== t)].slice(0, HISTORY_MAX)));
+}
+
+/** Past messages for what is being written: those it begins, then those
+ * that contain it. */
+function suggestions(typed: string, limit = 12) {
+ const q = typed.trim().toLowerCase();
+ if (!q) return [];
+ const starts: string[] = [];
+ const within: string[] = [];
+ for (const past of sentHistory()) {
+ const low = past.toLowerCase();
+ if (low === q) continue;
+ if (low.startsWith(q)) starts.push(past);
+ else if (low.includes(q)) within.push(past);
+ }
+ return [...starts, ...within].slice(0, limit);
+}
+
// ---------------------------------------------------------------------------
// Navigation. Every screen is pushed or popped: the new one slides in over the
// old, the way a phone's own apps move, and back undoes it.
@@ -129,6 +224,7 @@ interface ScreenParts {
/** A screen with a large title that folds into the bar as it scrolls away. */
function screen(parts: ScreenParts) {
+ const root = !parts.back;
const bar = h(
"header",
{ class: "nav" },
@@ -144,22 +240,37 @@ function screen(parts: ScreenParts) {
),
),
h("div", { class: "nav-title" }, parts.title),
- h("div", { class: "nav-trail" }, ...(parts.trailing ?? [])),
+ h("div", { class: "nav-trail" }, ...(root ? [] : (parts.trailing ?? []))),
);
const large = h("h1", { class: "large-title" }, parts.title);
+ // A top-level screen keeps its buttons beside the large title, not in a
+ // bar of their own above it.
+ const heading = root && parts.trailing?.length
+ ? h("div", { class: "title-row" }, large, h("div", { class: "title-trail" }, ...parts.trailing))
+ : large;
const scroll = h(
"main",
{ class: "scroll" },
- h("div", { class: "title-block" }, large, parts.subtitle),
+ h("div", { class: "title-block" }, heading, parts.subtitle),
...parts.body,
);
- new IntersectionObserver(
- ([entry]) => bar.classList.toggle("folded", !entry.isIntersecting),
- { root: scroll, threshold: 0, rootMargin: "-8px 0px 0px 0px" },
- ).observe(large);
+ if (root) {
+ // The bar floats over a top-level screen, empty until the title has
+ // scrolled up under it.
+ scroll.addEventListener(
+ "scroll",
+ () => bar.classList.toggle("folded", scroll.scrollTop > large.offsetTop + large.offsetHeight - bar.offsetHeight),
+ { passive: true },
+ );
+ } else {
+ new IntersectionObserver(
+ ([entry]) => bar.classList.toggle("folded", !entry.isIntersecting),
+ { root: scroll, threshold: 0, rootMargin: "-8px 0px 0px 0px" },
+ ).observe(large);
+ }
const view = h("div", { class: "screen" }, bar, scroll, parts.footer, parts.dock);
if (parts.dock) view.classList.add("docked");
- if (!parts.back && !parts.trailing?.length) view.classList.add("root");
+ if (root) view.classList.add("root");
return view;
}
@@ -224,7 +335,12 @@ function hostsScreen(direction: "push" | "pop" = "pop") {
render();
}, { label: "Pair a machine", run: () => pairScreen() });
dock.hidden = true;
- const view = screen({ title: "Machines", body: [body], dock });
+ const view = screen({
+ title: "Machines",
+ trailing: [h("button", { class: "nav-icon", ariaLabel: "Settings", onclick: () => settingsScreen() }, ico("settings"))],
+ body: [body],
+ dock,
+ });
const render = () => {
const shown = all.filter((host) => host.name.toLowerCase().includes(query));
@@ -260,6 +376,94 @@ function hostsScreen(direction: "push" | "pop" = "pop") {
});
}
+// ---------------------------------------------------------------------------
+// Settings
+
+const APPEARANCES: { value: Appearance; label: string }[] = [
+ { value: "system", label: "Automatic" },
+ { value: "light", label: "Light" },
+ { value: "dark", label: "Dark" },
+];
+
+const TEXT_SIZES = [
+ { value: 10, label: "Small" },
+ { value: 11, label: "Default" },
+ { value: 13, label: "Large" },
+ { value: 15, label: "Larger" },
+];
+
+const WIDE = [
+ { value: "readable", label: "Keep the text readable", meta: "Pan sideways" },
+ { value: "fit", label: "Fit the whole width", meta: "Smaller text" },
+] as const;
+
+function settingsScreen() {
+ go("push", () => {
+ const body = h("div", { class: "stack" });
+ const version = h("span", { class: "row-meta" });
+ void getVersion().then((v) => (version.textContent = v), () => {});
+
+ /** One setting's options, as rows with a check on the chosen one. */
+ const choices = (options: readonly { value: T; label: string; meta?: string }[], chosen: T, pick: (v: T) => void, sample?: (v: T) => Child) =>
+ options.map((o) =>
+ h(
+ "button",
+ { class: "row choice", onclick: () => (pick(o.value), draw()) },
+ h("span", { class: "row-title" }, o.label),
+ sample ? sample(o.value) : o.meta && h("span", { class: "row-meta" }, o.meta),
+ o.value === chosen ? ico("check", "icon choice-check") : h("span", { class: "choice-check" }),
+ ),
+ );
+ const note = (group: HTMLElement, text: string) => (group.append(h("p", { class: "group-note" }, text)), group);
+
+ const draw = () => {
+ const saved = sentHistory().length;
+ const clear = h(
+ "button",
+ { class: "row choice", disabled: saved === 0 },
+ h("span", { class: saved ? "row-title danger" : "row-title" }, "Clear message history"),
+ h("span", { class: "row-meta" }, saved ? `${saved} saved` : "Empty"),
+ );
+ clear.onclick = () => {
+ remember("history", "[]");
+ draw();
+ };
+ body.replaceChildren(
+ section("Appearance", ...choices(APPEARANCES, prefs.appearance, (v) => setPref("appearance", v))),
+ note(
+ section(
+ "Terminal text size",
+ ...choices(TEXT_SIZES, prefs.textSize, (v) => setPref("textSize", v), (v) => {
+ const aa = h("span", { class: "row-meta size-sample" }, "~/tty7 $");
+ aa.style.fontSize = `${v}px`;
+ return aa;
+ }),
+ ),
+ "The size panes are read at. Applies to the next pane you open.",
+ ),
+ note(
+ section("Panes wider than the phone", ...choices(WIDE, prefs.wide, (v) => setPref("wide", v))),
+ "How such a pane first shows. Switch any time from its ⋯ menu.",
+ ),
+ note(
+ section("Message history", clear),
+ "What you send from the message box is kept on this phone, to suggest as you write and to search from the history button.",
+ ),
+ section(
+ "About",
+ h("div", { class: "row choice static" }, h("span", { class: "row-title" }, "Version"), version),
+ ),
+ );
+ };
+ draw();
+ return screen({
+ title: "Settings",
+ back: { label: "Machines", onclick: () => hostsScreen() },
+ body: [body],
+ });
+ });
+}
+
function hostRow(host: Host) {
const meta = hostMeta(host.id);
return h(
@@ -863,16 +1067,8 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void)
};
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")),
- ),
+ const { remove } = openSheet(
+ "New tab",
h("div", { class: "sheet-body" },
h("section", { class: "sheet-group" }, h("h3", { class: "group-title" }, "Agent"), agents),
h(
@@ -885,11 +1081,6 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void)
),
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];
@@ -899,7 +1090,7 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void)
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());
- scrim.remove();
+ 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) {
@@ -908,14 +1099,36 @@ function newTabSheet(host: Host, tree: Tree, failed: (message: string) => void)
open.disabled = false;
}
};
+}
+
+/** A sheet risen over the dimmed screen, with a title and a way to close. */
+function openSheet(title: string, ...content: Child[]) {
+ const sheet = h(
+ "div",
+ { class: "sheet", role: "dialog", ariaLabel: title },
+ h("span", { class: "sheet-grip" }),
+ h(
+ "div",
+ { class: "sheet-head" },
+ h("h2", { class: "sheet-title" }, title),
+ h("button", { class: "round", ariaLabel: "Close", onclick: () => close() }, ico("close")),
+ ),
+ ...content,
+ );
+ const scrim = h("div", { class: "scrim", onclick: (e: Event) => e.target === scrim && close() }, sheet);
+ const close = () => {
+ scrim.classList.add("leaving");
+ setTimeout(() => scrim.remove(), still.matches ? 0 : 220);
+ };
document.body.append(scrim);
+ return { close, remove: () => scrim.remove() };
}
/** The grid that fills this screen at the readable size: what a tab started
* here is spawned at, since no desktop window is showing it yet. */
function phoneGrid() {
- const cellW = READABLE_PX * CELL_EM;
- const cellH = READABLE_PX * 1.18;
+ const cellW = readablePx() * CELL_EM;
+ const cellH = readablePx() * 1.18;
// The terminal screen's bar, its dock (keys, page dots, message box, the
// home indicator's gap) and the xterm padding.
const chrome = 56 + 132 + 16;
@@ -1080,10 +1293,8 @@ const ANSI = {
};
const NAMES = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] as const;
-const darkScheme = matchMedia("(prefers-color-scheme: dark)");
-
function terminalTheme(): ITheme {
- const dark = darkScheme.matches;
+ const dark = isDark();
const ansi = dark ? ANSI.dark : ANSI.light;
const theme: Record = dark
? { background: "#1e1e20", foreground: "#ececed", cursor: "#ececed", cursorAccent: "#1e1e20", selectionBackground: "#ffffff2e" }
@@ -1149,6 +1360,8 @@ const KEY_PAGES: Key[][] = [
{ label: "~", seq: "~" },
{ label: "-", seq: "-" },
{ label: "_", seq: "_" },
+ // The shell's own history search.
+ { label: "Search history", text: "^R", seq: "\x12", flex: 1.2 },
{ label: "Home", seq: "\x1b[H", flex: 1.4 },
{ label: "End", seq: "\x1b[F", flex: 1.4 },
],
@@ -1156,8 +1369,9 @@ const KEY_PAGES: Key[][] = [
/** Hack's advance width, in ems — what the fit divides by. */
const CELL_EM = 0.602;
-/** The smallest font a pane is read at before it pans instead of shrinking. */
-const READABLE_PX = 11;
+/** The smallest font a pane is read at before it pans instead of shrinking:
+ * the text size chosen in Settings. */
+const readablePx = () => prefs.textSize;
/** What was typed into a pane's compose box and not sent yet, by pane. Kept
* across leaving the pane, and across a send that did not get through. */
@@ -1237,7 +1451,11 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
// 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);
+ // Past messages: the whole list, searchable, while the box is empty; the
+ // ones that match, in place of the key row, as it is written in.
+ const historyKey = h("button", { class: "round", ariaLabel: "History" }, ico("history"));
+ const compose = h("div", { class: "compose" }, historyKey, field, sendKey, keyboard);
+ const suggest = h("div", { class: "suggest", hidden: true });
// What typing straight into the terminal goes through. xterm's own hidden
// textarea is not: iOS input methods never commit into it (a pinyin
// candidate stays unwritten), so this one, a plain field the keyboard
@@ -1258,13 +1476,13 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
{ class: "screen term-screen" },
bar,
h("div", { class: "term-wrap" }, screenEl, typing, copyView, banner),
- h("div", { class: "term-dock" }, pages, dots, compose),
+ h("div", { class: "term-dock" }, h("div", { class: "key-slot" }, pages, suggest), dots, compose),
);
const term = new Terminal({
cols: 80,
rows: 24,
- fontSize: 11,
+ fontSize: readablePx(),
fontFamily: "Hack, Menlo, ui-monospace, monospace",
scrollback: 5000,
cursorBlink: false,
@@ -1395,9 +1613,91 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
// Written, the round button sends; empty, it is the keyboard's.
sendKey.hidden = !field.value;
keyboard.hidden = !!field.value;
+ historyKey.hidden = !!field.value;
grow();
+ offer();
+ };
+ const offer = () => {
+ const found = document.activeElement === field ? suggestions(field.value) : [];
+ suggest.replaceChildren(
+ ...found.map((past) => {
+ const chip = h("button", { class: "suggestion" }, past);
+ chip.onpointerdown = (e) => e.preventDefault();
+ chip.onclick = () => {
+ field.value = past;
+ edited();
+ };
+ return chip;
+ }),
+ );
+ suggest.hidden = found.length === 0;
+ suggest.scrollLeft = 0;
+ pages.classList.toggle("covered", found.length > 0);
+ dots.classList.toggle("covered", found.length > 0);
+ };
+ field.addEventListener("focus", offer);
+ field.addEventListener("blur", offer);
+
+ historyKey.onpointerdown = (e) => e.preventDefault();
+ historyKey.onclick = () => {
+ const all = sentHistory();
+ const search = h("input", {
+ type: "search",
+ class: "search-input",
+ placeholder: "Search history",
+ enterKeyHint: "search",
+ autocapitalize: "off",
+ spellcheck: false,
+ ariaLabel: "Search history",
+ });
+ search.setAttribute("autocorrect", "off");
+ const list = h("div", { class: "card" });
+ const empty = h("p", { class: "group-empty" });
+ const fill = () => {
+ const q = search.value.trim().toLowerCase();
+ const shown = all.filter((past) => past.toLowerCase().includes(q));
+ list.replaceChildren(
+ ...shown.map((past) =>
+ h(
+ "button",
+ {
+ class: "row choice past",
+ onclick: () => {
+ remove();
+ field.value = past;
+ edited();
+ field.focus({ preventScroll: true });
+ },
+ },
+ h("span", { class: "row-title" }, past),
+ ),
+ ),
+ );
+ list.hidden = shown.length === 0;
+ empty.hidden = shown.length > 0;
+ empty.textContent = all.length
+ ? `Nothing sent matches “${search.value.trim()}”.`
+ : "What you send from the message box shows up here.";
+ };
+ search.oninput = fill;
+ search.onkeydown = (e) => {
+ if (e.key === "Enter") search.blur();
+ };
+ const { remove } = openSheet(
+ "History",
+ h("label", { class: "search sheet-search" }, ico("search"), search),
+ h("div", { class: "sheet-body" }, list, empty),
+ );
+ fill();
};
field.addEventListener("input", edited);
+ // A password typed into the box is sent, never kept: the line the cursor
+ // is on says what the pane asked for.
+ const answersSecret = () => {
+ const buf = term.buffer.active;
+ const line = buf.getLine(buf.baseY + buf.cursorY)?.translateToString(true) ?? "";
+ return /pass(word|phrase)|\bpin\b|密码|口令/i.test(line);
+ };
const submit = async () => {
const body = field.value.replace(/\r?\n/g, "\r");
// Several lines go in as one paste where the program asked for that,
@@ -1408,6 +1708,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
// how the bytes arrive would otherwise take it as part of the text.
// An empty box sends Enter alone.
if (!(await input("\r"))) return;
+ if (!answersSecret()) keepSent(field.value);
field.value = "";
edited();
};
@@ -1471,23 +1772,23 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
// until every column shows; readable, the font stays legible and the view
// pans sideways, following the cursor. A pane that fits legibly is simply
// fitted, and the toggle has nothing to offer.
- let readable = true;
+ let readable = prefs.wide === "readable";
const fittedSize = () => {
const width = screenEl.clientWidth - 12;
- return width <= 0 ? READABLE_PX : Math.min(14, Math.floor((width / cols / CELL_EM) * 10) / 10);
+ return width <= 0 ? readablePx() : Math.min(Math.max(14, readablePx()), Math.floor((width / cols / CELL_EM) * 10) / 10);
};
const fit = () => {
// Taken over, the pane is exactly the phone's width at the readable
// size: nothing to shrink, nothing to pan.
if (leased) {
cramped = false;
- term.options.fontSize = READABLE_PX;
+ term.options.fontSize = readablePx();
screenEl.classList.remove("panning");
return;
}
const fitted = fittedSize();
- cramped = fitted < READABLE_PX;
- term.options.fontSize = cramped && readable ? READABLE_PX : Math.max(4, fitted);
+ cramped = fitted < readablePx();
+ term.options.fontSize = cramped && readable ? readablePx() : Math.max(4, fitted);
screenEl.classList.toggle("panning", cramped && readable);
follow();
// The new font size is laid out on the next frame.
@@ -1696,13 +1997,13 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
// A row's height, measured off what xterm drew and scaled to the size
// the pane will be read at; the metrics guess only before the first draw.
const drawn = screenEl.querySelector(".xterm-screen");
- const now = term.options.fontSize ?? READABLE_PX;
+ const now = term.options.fontSize ?? readablePx();
const rowH =
drawn && term.rows && drawn.clientHeight
- ? ((drawn.clientHeight / term.rows) * READABLE_PX) / now
- : READABLE_PX * 1.18;
+ ? ((drawn.clientHeight / term.rows) * readablePx()) / now
+ : readablePx() * 1.18;
return {
- cols: Math.max(20, Math.floor(width / (READABLE_PX * CELL_EM))),
+ cols: Math.max(20, Math.floor(width / (readablePx() * CELL_EM))),
rows: Math.max(5, Math.floor(height / rowH)),
};
};
@@ -1808,7 +2109,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
requestAnimationFrame(showCursor);
};
window.addEventListener("viewport", viewport);
- darkScheme.addEventListener("change", retheme);
+ window.addEventListener("theme", retheme);
const setState = (cls: string, label: string) => {
state.className = `term-state ${cls}`;
@@ -1929,7 +2230,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
window.removeEventListener("resize", regridSoon);
window.removeEventListener("viewport", viewport);
clearTimeout(regrid);
- darkScheme.removeEventListener("change", retheme);
+ window.removeEventListener("theme", retheme);
if (handle !== null) api.paneClose(handle);
term.dispose();
};
diff --git a/mobile/src/style.css b/mobile/src/style.css
index 9f87ea94..760faa50 100644
--- a/mobile/src/style.css
+++ b/mobile/src/style.css
@@ -57,7 +57,7 @@
it (main.ts sets `keyboard` on ). */
--safe-bottom: env(safe-area-inset-bottom);
- color-scheme: light dark;
+ color-scheme: light;
font: 17px/1.35 var(--ui);
letter-spacing: -0.005em;
color: var(--ink);
@@ -66,28 +66,29 @@
-webkit-font-smoothing: antialiased;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --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: #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);
- }
+/* Dark: the system's, or the one chosen in Settings (main.ts sets
+ `data-theme` on ). */
+:root[data-theme="dark"] {
+ --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: #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);
+ color-scheme: dark;
}
* {
@@ -323,13 +324,27 @@ code {
}
/* A top-level screen has nothing in its bar until the title folds into it:
- the title starts 34pt under the status bar instead. */
+ the bar floats over the list, clear until then, and the large title sits
+ just under the status bar. */
.screen.root .nav {
- height: calc(34px + env(safe-area-inset-top));
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ background: transparent;
+ pointer-events: none;
+}
+
+.screen.root .nav.folded {
+ background: var(--bar);
+}
+
+.screen.root .scroll {
+ padding-top: env(safe-area-inset-top);
}
.screen.root .title-block {
- padding-top: 0;
+ padding-top: 8px;
}
/* Clear of the floating bar at the bottom. */
@@ -337,6 +352,19 @@ code {
padding-bottom: calc(104px + var(--safe-bottom));
}
+.title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.title-trail {
+ display: flex;
+ flex: none;
+ gap: 8px;
+}
+
.large-title {
font-size: 34px;
line-height: 1.12;
@@ -1516,7 +1544,7 @@ html.scanning #app {
.scrim {
position: fixed;
- inset: 0;
+ inset: 0 0 var(--keyboard, 0px);
z-index: 20;
display: flex;
align-items: flex-end;
@@ -1741,3 +1769,86 @@ html[data-nav="pop"]::view-transition-new(root) {
transition: none !important;
}
}
+
+/* ------------------------------------------------------------------------ */
+/* Past messages: suggested over the key row as the box is written in, and
+ the History sheet. */
+
+.key-slot {
+ position: relative;
+}
+
+.key-pages.covered,
+.key-dots.covered {
+ visibility: hidden;
+}
+
+.suggest {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ gap: 6px;
+ overflow-x: auto;
+ scrollbar-width: none;
+ overscroll-behavior-x: contain;
+}
+
+.suggest::-webkit-scrollbar {
+ display: none;
+}
+
+.suggestion {
+ flex: none;
+ max-width: 78%;
+ height: 42px;
+ padding: 0 14px;
+ border-radius: 10px;
+ background: var(--cell);
+ box-shadow:
+ 0 0 0 0.5px var(--hair),
+ 0 1px 0 var(--hair);
+ font: 14px var(--mono);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.suggestion:active {
+ background: var(--cell-press);
+}
+
+.sheet-search {
+ flex: none;
+ height: 44px;
+ background: var(--cell);
+ box-shadow: 0 0 0 0.5px var(--hair);
+}
+
+.row.choice.past .row-title {
+ font: 14px/1.4 var(--mono);
+ white-space: normal;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow-wrap: anywhere;
+}
+
+/* ------------------------------------------------------------------------ */
+/* Settings */
+
+.row-title.danger {
+ color: var(--danger);
+}
+
+.row.choice:disabled {
+ opacity: 0.5;
+}
+
+.row.static:active {
+ background: none;
+}
+
+.size-sample {
+ font-family: var(--mono);
+ color: var(--ink-soft);
+}