prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

shell.js (25564B)


      1 // Interactive prompt at the bottom of the about page. Starts from the same
      2 // virtual fs as the SSH shell (internal/tui/shell.go, served at /api/fs) and
      3 // layers the visitor's own changes on top in localStorage (shell-fs.js).
      4 //
      5 // It tries to feel like a real terminal over ssh: a little round-trip latency
      6 // per command, output that streams in instead of appearing at once, typeahead
      7 // that queues while a command runs, ^C to interrupt, double-tab listings.
      8 
      9 import { isEditableTarget } from "./dom.js";
     10 import { COMMAND_NAMES, SPECIAL, commands, longDate, writeFile } from "./shell-cmds.js";
     11 import { STORE_KEY, VFS, displayPath, lookup, resolvePath, sortedChildren } from "./shell-fs.js";
     12 import { expandWord, parse, resolveWord, tokenize } from "./shell-lang.js";
     13 
     14 const MAX_LINES = 2000;
     15 const MAX_HISTORY = 500;
     16 const HIST_KEY = "prime8.history.v1";
     17 const LOGIN_KEY = "prime8.lastlogin.v1";
     18 const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
     19 
     20 let fsPromise = null;
     21 const shellHandlers = { keydown: null, storage: null, click: null, winFocus: null, revealed: null };
     22 
     23 function loadFS() {
     24   if (!fsPromise) {
     25     fsPromise = fetch("/api/fs", { credentials: "same-origin" })
     26       .then((res) => (res.ok ? res.json() : Promise.reject(new Error(String(res.status)))))
     27       .then((data) => new VFS(data))
     28       .catch((err) => {
     29         fsPromise = null;
     30         throw err;
     31       });
     32   }
     33   return fsPromise;
     34 }
     35 
     36 function store(key, value) {
     37   try {
     38     window.localStorage.setItem(key, value);
     39   } catch {
     40     /* private mode, full, ... */
     41   }
     42 }
     43 
     44 function load(key) {
     45   try {
     46     return window.localStorage.getItem(key);
     47   } catch {
     48     return null;
     49   }
     50 }
     51 
     52 function sleep(ms) {
     53   return new Promise((r) => setTimeout(r, ms));
     54 }
     55 
     56 function jitter(min, max) {
     57   return min + Math.random() * (max - min);
     58 }
     59 
     60 function nextFrame() {
     61   return new Promise((r) => requestAnimationFrame(() => r()));
     62 }
     63 
     64 function commonPrefix(words) {
     65   if (words.length === 0) return "";
     66   const sorted = [...words].sort();
     67   const first = sorted[0];
     68   const last = sorted[sorted.length - 1];
     69   let i = 0;
     70   while (i < first.length && i < last.length && first[i] === last[i]) i++;
     71   return first.slice(0, i);
     72 }
     73 
     74 function span(cls, text) {
     75   const el = document.createElement("span");
     76   el.className = cls;
     77   el.textContent = text;
     78   return el;
     79 }
     80 
     81 function promptNodes(user, path, input) {
     82   return [
     83     span("shell-user", user),
     84     document.createTextNode("@"),
     85     span("shell-host", "prime8"),
     86     document.createTextNode(":"),
     87     span("shell-path", path),
     88     span("shell-dollar", "$"),
     89     document.createTextNode(" "),
     90     span("shell-cmd", input),
     91   ];
     92 }
     93 
     94 export function initShell() {
     95   const section = document.getElementById("shell");
     96   if (!section || section.dataset.bound === "true") return;
     97   section.dataset.bound = "true";
     98 
     99   const layout = section.closest(".about-layout");
    100   const historyEl = section.querySelector(".shell-history");
    101   const live = section.querySelector(".shell-live");
    102   const input = section.querySelector(".shell-input");
    103   const pathEl = live?.querySelector(".shell-path");
    104   const beforeEl = live?.querySelector("[data-before]");
    105   const afterEl = live?.querySelector("[data-after]");
    106   const cursorEl = live?.querySelector(".shell-cursor");
    107   if (!historyEl || !live || !input || !pathEl || !beforeEl || !afterEl || !cursorEl) return;
    108 
    109   const initialUser = section.dataset.user || "visitor";
    110   let saved = [];
    111   try {
    112     saved = JSON.parse(load(HIST_KEY) || "[]");
    113   } catch {
    114     saved = [];
    115   }
    116 
    117   // --- shell state ---
    118 
    119   const sh = {
    120     fs: null,
    121     user: initialUser,
    122     home: "/home/" + initialUser,
    123     cwd: "/home/" + initialUser,
    124     oldpwd: "",
    125     status: 0,
    126     bootTime: Date.now(),
    127     history: Array.isArray(saved) ? saved.filter((l) => typeof l === "string").slice(-MAX_HISTORY) : [],
    128     vars: {},
    129     get env() {
    130       return {
    131         HOME: this.home,
    132         USER: this.user,
    133         LOGNAME: this.user,
    134         HOSTNAME: "prime8",
    135         SHELL: "/bin/bash",
    136         TERM: "xterm-256color",
    137         LANG: "en_US.UTF-8",
    138         PATH: "/usr/local/bin:/usr/bin:/bin",
    139         PWD: this.cwd,
    140         ...(this.oldpwd ? { OLDPWD: this.oldpwd } : {}),
    141         ...this.vars,
    142         "?": this.status,
    143       };
    144     },
    145     resolve(p) {
    146       return resolvePath(this.home, this.cwd, p);
    147     },
    148     chdir(abs) {
    149       this.oldpwd = this.cwd;
    150       this.cwd = abs;
    151     },
    152     setVar(k, v) {
    153       this.vars[k] = v;
    154     },
    155     unsetVar(k) {
    156       delete this.vars[k];
    157     },
    158     clearHistory() {
    159       this.history.length = 0;
    160       store(HIST_KEY, "[]");
    161     },
    162   };
    163 
    164   let recallIndex = sh.history.length;
    165   let draft = "";
    166   let busy = false;
    167   let job = null;
    168   const typeahead = [];
    169   let lastTab = "";
    170 
    171   function scrollToPrompt() {
    172     live.scrollIntoView({ block: "nearest" });
    173   }
    174 
    175   let lastValue = null;
    176   let lastCaret = -1;
    177   function render() {
    178     pathEl.textContent = displayPath(sh.home, sh.cwd);
    179     const value = input.value;
    180     const caret = input.selectionStart ?? value.length;
    181     lastValue = value;
    182     lastCaret = caret;
    183     beforeEl.textContent = value.slice(0, caret);
    184     cursorEl.textContent = value.slice(caret, caret + 1) || "\u00a0";
    185     afterEl.textContent = value.slice(caret + 1);
    186     holdCursor();
    187   }
    188 
    189   // Real terminals restart the blink on every keystroke, so the cursor stays
    190   // solid while you type and only starts blinking again once you pause.
    191   let blinkTimer = 0;
    192   function holdCursor() {
    193     cursorEl.classList.add("solid");
    194     clearTimeout(blinkTimer);
    195     blinkTimer = setTimeout(() => cursorEl.classList.remove("solid"), 530);
    196   }
    197 
    198   function setInput(value, caret = value.length) {
    199     input.value = value;
    200     input.setSelectionRange(caret, caret);
    201     render();
    202   }
    203 
    204   function trim() {
    205     while (historyEl.childElementCount > MAX_LINES) historyEl.firstElementChild?.remove();
    206   }
    207 
    208   function appendLine(nodes, cls) {
    209     const line = document.createElement("div");
    210     line.className = cls;
    211     if (typeof nodes === "string") line.textContent = nodes;
    212     else line.append(...nodes);
    213     historyEl.appendChild(line);
    214     trim();
    215     return line;
    216   }
    217 
    218   function clearScreen() {
    219     term.reset();
    220     historyEl.replaceChildren();
    221     layout?.classList.add("shell-cleared");
    222   }
    223 
    224   // --- terminal output: queued lines drained a few per frame ---
    225 
    226   const term = {
    227     q: [],
    228     open: null,
    229     pumping: null,
    230     write(text, cls = "") {
    231       const parts = text.split("\n");
    232       parts.forEach((p, i) => {
    233         const end = i < parts.length - 1;
    234         if (!end && p === "") return;
    235         this.q.push({ text: p, cls, end });
    236       });
    237       this.kick();
    238     },
    239     line(nodes, cls = "") {
    240       this.q.push({ nodes, cls, end: true });
    241       this.kick();
    242     },
    243     kick() {
    244       if (!this.pumping) this.pumping = this.pump();
    245     },
    246     emit(item) {
    247       let el = this.open;
    248       if (!el) {
    249         el = appendLine("", "shell-out shell-stream" + (item.cls === "err" ? " shell-err" : ""));
    250       }
    251       if (item.nodes) el.append(...item.nodes);
    252       else if (item.text) el.append(document.createTextNode(item.text));
    253       if (item.end) {
    254         if (!el.textContent) el.textContent = " ";
    255         this.open = null;
    256       } else this.open = el;
    257     },
    258     async pump() {
    259       // Yield once so push() has stored this promise before we can clear it;
    260       // a synchronous finish would leave a settled promise in `pumping` and
    261       // drain() would spin on it forever.
    262       await null;
    263       while (this.q.length) {
    264         const n = reducedMotion() ? this.q.length : Math.max(2, Math.ceil(this.q.length / 24));
    265         for (let i = 0; i < n && this.q.length; i++) this.emit(this.q.shift());
    266         scrollToPrompt();
    267         if (this.q.length) await nextFrame();
    268       }
    269       this.pumping = null;
    270     },
    271     async drain() {
    272       while (this.pumping) await this.pumping;
    273     },
    274     endLine() {
    275       this.open = null;
    276     },
    277     reset() {
    278       this.q.length = 0;
    279       this.open = null;
    280     },
    281   };
    282 
    283   // --- running commands ---
    284 
    285   function newJob() {
    286     const j = { aborted: false, wakers: new Set(), exit: false };
    287     j.sleep = (ms) =>
    288       new Promise((resolve) => {
    289         if (j.aborted) return resolve(false);
    290         const wake = () => {
    291           clearTimeout(t);
    292           resolve(false);
    293         };
    294         const t = setTimeout(() => {
    295           j.wakers.delete(wake);
    296           resolve(true);
    297         }, ms);
    298         j.wakers.add(wake);
    299       });
    300     j.abort = () => {
    301       j.aborted = true;
    302       for (const w of j.wakers) w();
    303       j.wakers.clear();
    304     };
    305     return j;
    306   }
    307 
    308   function expand(words) {
    309     const argv = [];
    310     for (const raw of words) {
    311       const w = resolveWord(raw, sh.env);
    312       if (!w.quoted && w.v === "") continue;
    313       argv.push(...expandWord(w, sh.fs.root, sh.home, sh.cwd));
    314     }
    315     return argv;
    316   }
    317 
    318   function shErr(msg) {
    319     term.write(`bash: ${msg}\n`, "err");
    320   }
    321 
    322   async function runCommand(cmd, stdin, last) {
    323     const argv = expand(cmd.words);
    324 
    325     // NAME=value with no command sets a shell variable
    326     const assign = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
    327     if (argv.length && argv.every((a) => assign.test(a)) && cmd.redirs.length === 0) {
    328       for (const a of argv) {
    329         const m = assign.exec(a);
    330         sh.setVar(m[1], m[2]);
    331       }
    332       return { status: 0, stdout: "" };
    333     }
    334     while (argv.length > 1 && assign.test(argv[0])) argv.shift();
    335 
    336     let input = stdin;
    337     let outFile = null;
    338     let errFile = null;
    339     let errToOut = false;
    340     for (const r of cmd.redirs) {
    341       if (r.op === "2>&1") {
    342         errToOut = true;
    343         continue;
    344       }
    345       const targets = expand([r.target]);
    346       if (targets.length !== 1) {
    347         shErr(`${resolveWord(r.target, sh.env).v}: ambiguous redirect`);
    348         return { status: 1, stdout: "" };
    349       }
    350       const t = targets[0];
    351       if (r.op === "<") {
    352         const n = sh.fs.get(sh.resolve(t));
    353         if (!n || n.dir) {
    354           shErr(`${t}: ${n ? "Is a directory" : "No such file or directory"}`);
    355           return { status: 1, stdout: "" };
    356         }
    357         input = n.content;
    358         continue;
    359       }
    360       const append = r.op.endsWith(">>");
    361       // the shell creates (and truncates) the target before the command runs
    362       const why = writeFile(sh, t, "", true);
    363       if (why) {
    364         shErr(`${t}: ${why}`);
    365         return { status: 1, stdout: "" };
    366       }
    367       if (!append) writeFile(sh, t, "", false);
    368       if (r.op.startsWith("2")) errFile = { path: t, buf: "" };
    369       else outFile = { path: t, buf: "" };
    370     }
    371 
    372     if (argv.length === 0) return { status: 0, stdout: "" };
    373     const name = argv[0];
    374     const toTerm = last && !outFile;
    375     let piped = "";
    376     const out = (s) => {
    377       if (outFile) outFile.buf += s;
    378       else if (toTerm) term.write(s);
    379       else piped += s;
    380     };
    381     const err = (s) => {
    382       if (errToOut) out(s);
    383       else if (errFile) errFile.buf += s;
    384       else term.write(s, "err");
    385     };
    386 
    387     let status;
    388     if (SPECIAL.has(name)) {
    389       if (name === "clear" || name === "reset") {
    390         if (toTerm) {
    391           await term.drain();
    392           clearScreen();
    393         }
    394       } else job.exit = true;
    395       status = 0;
    396     } else if (commands[name]) {
    397       const ctx = {
    398         argv,
    399         stdin: input,
    400         tty: toTerm,
    401         sh,
    402         out,
    403         err,
    404         line: (nodes) => term.line(nodes),
    405         span,
    406         sleep: job.sleep,
    407       };
    408       try {
    409         status = await commands[name](ctx);
    410       } catch (e) {
    411         err(`${name}: ${e && e.message ? e.message : "internal error"}\n`);
    412         status = 1;
    413       }
    414     } else if (name.includes("/")) {
    415       const n = sh.fs.get(sh.resolve(name));
    416       err(`bash: ${name}: ${n ? (n.dir ? "Is a directory" : "Permission denied") : "No such file or directory"}\n`);
    417       status = n ? 126 : 127;
    418     } else {
    419       err(`${name}: command not found\n`);
    420       status = 127;
    421     }
    422 
    423     for (const f of [outFile, errFile]) {
    424       if (!f || !f.buf) continue;
    425       const why = writeFile(sh, f.path, f.buf, true);
    426       if (why) shErr(`${f.path}: ${why}`);
    427     }
    428     return { status: status ?? 0, stdout: piped };
    429   }
    430 
    431   async function runLine(line) {
    432     const tk = tokenize(line, sh.env);
    433     if (tk.error) {
    434       shErr(tk.error);
    435       sh.status = 2;
    436       return;
    437     }
    438     const parsed = parse(tk.toks);
    439     if (parsed.error) {
    440       shErr(parsed.error);
    441       sh.status = 2;
    442       return;
    443     }
    444     for (const item of parsed.list) {
    445       if (item.conn === "&&" && sh.status !== 0) continue;
    446       if (item.conn === "||" && sh.status === 0) continue;
    447       let stdin = null;
    448       for (let i = 0; i < item.pipeline.length; i++) {
    449         const r = await runCommand(item.pipeline[i], stdin, i === item.pipeline.length - 1);
    450         stdin = r.stdout;
    451         sh.status = r.status;
    452         if (job.aborted || job.exit) break;
    453       }
    454       term.endLine();
    455       if (job.aborted) {
    456         sh.status = 130;
    457         return;
    458       }
    459       if (job.exit) return;
    460     }
    461   }
    462 
    463   function remember(line) {
    464     if (!line.trim() || line.startsWith(" ") || sh.history[sh.history.length - 1] === line) return;
    465     sh.history.push(line);
    466     if (sh.history.length > MAX_HISTORY) sh.history.shift();
    467     store(HIST_KEY, JSON.stringify(sh.history));
    468   }
    469 
    470   async function execute(line) {
    471     remember(line);
    472     recallIndex = sh.history.length;
    473     draft = "";
    474     const first = line.trim().split(/\s+/)[0] || "";
    475     // like the TUI, clear wipes the screen without echoing itself
    476     if (first !== "clear" && first !== "reset") appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), line), "shell-prompt");
    477     if (!line.trim()) return;
    478 
    479     job = newJob();
    480     // network round trip; the first command also waits on the real fetch
    481     const rtt = job.sleep(jitter(18, 55));
    482     try {
    483       sh.fs = await loadFS();
    484     } catch {
    485       await rtt;
    486       term.write("prime8: filesystem unavailable, try again in a bit\n", "err");
    487       return;
    488     }
    489     if (sh.fs.user && sh.fs.user !== sh.user) {
    490       sh.user = sh.fs.user;
    491       sh.home = sh.fs.home;
    492       sh.cwd = sh.home;
    493     }
    494     await rtt;
    495     if (!job.aborted) await runLine(line);
    496     await term.drain();
    497     term.endLine();
    498     if (!sh.fs.commit()) term.write("bash: write error: No space left on device\n", "err");
    499     await term.drain();
    500     if (job.exit) {
    501       job = null;
    502       disconnect();
    503     }
    504   }
    505 
    506   async function submit(line) {
    507     if (busy) {
    508       typeahead.push(line);
    509       return;
    510     }
    511     busy = true;
    512     live.classList.add("busy");
    513     try {
    514       typeahead.unshift(line);
    515       while (typeahead.length && !live.hidden) await execute(typeahead.shift());
    516     } finally {
    517       typeahead.length = 0;
    518       job = null;
    519       busy = false;
    520       live.classList.remove("busy");
    521       render();
    522       scrollToPrompt();
    523     }
    524   }
    525 
    526   function interrupt() {
    527     if (!busy || !job) return false;
    528     typeahead.length = 0;
    529     job.abort();
    530     term.reset();
    531     appendLine("^C", "shell-out shell-stream");
    532     scrollToPrompt();
    533     return true;
    534   }
    535 
    536   // --- completion ---
    537 
    538   async function complete() {
    539     const value = input.value.slice(0, input.selectionStart ?? input.value.length);
    540     const rest = input.value.slice(value.length);
    541     const idx = Math.max(value.lastIndexOf(" "), value.lastIndexOf("|"), value.lastIndexOf(";"), value.lastIndexOf(">"), value.lastIndexOf("<"));
    542     const word = value.slice(idx + 1);
    543     const before = value.slice(0, idx + 1);
    544     const commandPos = !/\S/.test(before.replace(/.*[|;&]/, ""));
    545     let names;
    546     let dirPart = "";
    547     let suffix = (m) => m + " ";
    548     if (commandPos && !word.includes("/")) {
    549       names = COMMAND_NAMES.filter((c) => c.startsWith(word));
    550     } else {
    551       let fs;
    552       try {
    553         fs = await loadFS();
    554       } catch {
    555         return;
    556       }
    557       const slash = word.lastIndexOf("/");
    558       dirPart = slash >= 0 ? word.slice(0, slash + 1) : "";
    559       const base = slash >= 0 ? word.slice(slash + 1) : word;
    560       const dir = lookup(fs.root, resolvePath(sh.home, sh.cwd, dirPart || "."));
    561       if (!dir || !dir.dir) return;
    562       const kids = sortedChildren(dir).filter((c) => c.name.startsWith(base) && (base.startsWith(".") || !c.name.startsWith(".")));
    563       names = kids.map((c) => c.name + (c.dir ? "/" : ""));
    564       suffix = (m) => (m.endsWith("/") ? m : m + " ");
    565     }
    566     if (names.length === 0) return;
    567     if (names.length === 1) {
    568       const done = before + dirPart + suffix(names[0]);
    569       setInput(done + rest, done.length);
    570       lastTab = "";
    571       return;
    572     }
    573     const p = commonPrefix(names);
    574     if (p.length > word.length - dirPart.length) {
    575       const done = before + dirPart + p;
    576       setInput(done + rest, done.length);
    577       lastTab = "";
    578       return;
    579     }
    580     // second tab with nothing more to fill: list the candidates, like bash
    581     if (lastTab === input.value) {
    582       appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), input.value), "shell-prompt");
    583       appendLine(names.join("  "), "shell-out");
    584       scrollToPrompt();
    585       lastTab = "";
    586       return;
    587     }
    588     lastTab = input.value;
    589   }
    590 
    591   // --- session ---
    592 
    593   function disconnect() {
    594     term.reset();
    595     historyEl.replaceChildren();
    596     layout?.classList.add("shell-cleared");
    597     appendLine("logout", "shell-out");
    598     appendLine("Connection to prime8.dev closed.", "shell-out shell-dim");
    599     const again = document.createElement("button");
    600     again.type = "button";
    601     again.className = "shell-reconnect";
    602     again.textContent = "[ reconnect ]";
    603     again.addEventListener("click", reconnect, { once: true });
    604     const row = document.createElement("div");
    605     row.className = "shell-out";
    606     row.appendChild(again);
    607     historyEl.appendChild(row);
    608     live.hidden = true;
    609     input.blur();
    610     again.focus({ preventScroll: true });
    611   }
    612 
    613   async function reconnect() {
    614     historyEl.replaceChildren();
    615     // ssh handshake
    616     await sleep(jitter(250, 700));
    617     const prev = load(LOGIN_KEY);
    618     store(LOGIN_KEY, String(Date.now()));
    619     if (prev && !Number.isNaN(Number(prev))) appendLine(`Last login: ${longDate(new Date(Number(prev)))} from web`, "shell-out shell-dim");
    620     sh.cwd = sh.home;
    621     sh.oldpwd = "";
    622     sh.status = 0;
    623     live.hidden = false;
    624     setInput("");
    625     input.focus({ preventScroll: true });
    626     scrollToPrompt();
    627   }
    628 
    629   function deleteWord() {
    630     const caret = input.selectionStart ?? input.value.length;
    631     const head = input.value.slice(0, caret).replace(/\S+\s*$/, "");
    632     const tail = input.value.slice(caret);
    633     setInput(head + tail, head.length);
    634   }
    635 
    636   input.addEventListener("keydown", (e) => {
    637     if (e.isComposing) return;
    638     if (e.key !== "Tab") lastTab = "";
    639     const ctrl = e.ctrlKey && !e.metaKey && !e.altKey;
    640     if (ctrl) {
    641       switch (e.key.toLowerCase()) {
    642         case "l":
    643           e.preventDefault();
    644           if (!busy) clearScreen();
    645           return;
    646         case "a":
    647           e.preventDefault();
    648           setInput(input.value, 0);
    649           return;
    650         case "e":
    651           e.preventDefault();
    652           setInput(input.value);
    653           return;
    654         case "u": {
    655           e.preventDefault();
    656           const caret = input.selectionStart ?? 0;
    657           setInput(input.value.slice(caret), 0);
    658           return;
    659         }
    660         case "k": {
    661           e.preventDefault();
    662           const caret = input.selectionStart ?? input.value.length;
    663           setInput(input.value.slice(0, caret), caret);
    664           return;
    665         }
    666         case "w":
    667           e.preventDefault();
    668           deleteWord();
    669           return;
    670         case "c":
    671           if (input.selectionStart !== input.selectionEnd) return; // let copy work
    672           e.preventDefault();
    673           if (interrupt()) {
    674             setInput("");
    675             return;
    676           }
    677           appendLine(promptNodes(sh.user, displayPath(sh.home, sh.cwd), input.value + "^C"), "shell-prompt");
    678           setInput("");
    679           recallIndex = sh.history.length;
    680           scrollToPrompt();
    681           return;
    682         case "d":
    683           e.preventDefault();
    684           if (input.value === "" && !busy) disconnect();
    685           return;
    686         default:
    687           return;
    688       }
    689     }
    690 
    691     // A focused prompt types digits like any terminal. Tab on an empty prompt
    692     // has nothing to complete, so it gives focus back and cycles tabs.
    693     if (input.value === "" && !busy && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === "Tab") {
    694       e.preventDefault();
    695       e.stopPropagation();
    696       input.blur();
    697       document.dispatchEvent(new KeyboardEvent("keydown", { key: e.key, shiftKey: e.shiftKey, bubbles: true, cancelable: true }));
    698       return;
    699     }
    700 
    701     switch (e.key) {
    702       case "Enter": {
    703         e.preventDefault();
    704         const line = input.value;
    705         setInput("");
    706         submit(line);
    707         break;
    708       }
    709       case "Tab":
    710         // keep the tab-cycling shortcut out of the prompt
    711         e.preventDefault();
    712         e.stopPropagation();
    713         if (!busy) complete();
    714         break;
    715       case "ArrowUp":
    716         e.preventDefault();
    717         e.stopPropagation();
    718         if (busy) break;
    719         if (recallIndex === sh.history.length) draft = input.value;
    720         if (recallIndex > 0) setInput(sh.history[--recallIndex]);
    721         break;
    722       case "ArrowDown":
    723         e.preventDefault();
    724         e.stopPropagation();
    725         if (busy) break;
    726         if (recallIndex < sh.history.length) {
    727           recallIndex++;
    728           setInput(recallIndex === sh.history.length ? draft : sh.history[recallIndex]);
    729         }
    730         break;
    731       case "Escape":
    732         if (input.value) setInput("");
    733         else input.blur();
    734         break;
    735       default:
    736         break;
    737     }
    738   });
    739 
    740   input.addEventListener("input", render);
    741   input.addEventListener("select", render);
    742   // Only a real caret move re-renders: render() rewrites the cursor spans,
    743   // which itself fires selectionchange, and re-rendering on that kept the
    744   // cursor pinned solid forever (it never got to blink).
    745   document.addEventListener("selectionchange", () => {
    746     if (document.activeElement !== input) return;
    747     if (input.value === lastValue && input.selectionStart === lastCaret) return;
    748     render();
    749   });
    750   input.addEventListener("focus", () => {
    751     section.classList.add("focused");
    752     render();
    753     loadFS().catch(() => {});
    754   });
    755   input.addEventListener("blur", () => section.classList.remove("focused"));
    756 
    757   // another tab changed the files: pick them up
    758   if (shellHandlers.storage) window.removeEventListener("storage", shellHandlers.storage);
    759   shellHandlers.storage = (e) => {
    760     if (e.key !== STORE_KEY || !sh.fs || busy) return;
    761     sh.fs.load();
    762   };
    763   window.addEventListener("storage", shellHandlers.storage);
    764 
    765   // Typing with nothing focused goes to the prompt, but the tab/help
    766   // shortcuts (digits, ?, space, arrows, tab) keep working, like the TUI.
    767   if (shellHandlers.keydown) document.removeEventListener("keydown", shellHandlers.keydown);
    768   shellHandlers.keydown = (e) => {
    769     if (!section.isConnected) {
    770       document.removeEventListener("keydown", shellHandlers.keydown);
    771       shellHandlers.keydown = null;
    772       return;
    773     }
    774     if (live.hidden || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
    775     if (isEditableTarget(e.target) || window.isLightboxOpen?.()) return;
    776     const active = document.activeElement;
    777     if (active && active !== document.body && active !== document.documentElement) return;
    778     if (document.getElementById("help-overlay")?.classList.contains("visible")) return;
    779     if (e.key.length !== 1 || /[0-9? ]/.test(e.key)) return;
    780     e.preventDefault();
    781     input.focus({ preventScroll: true });
    782     setInput(input.value + e.key);
    783     scrollToPrompt();
    784   };
    785   document.addEventListener("keydown", shellHandlers.keydown);
    786 
    787   if (!load(LOGIN_KEY)) store(LOGIN_KEY, String(Date.now()));
    788   render();
    789 
    790   // The prompt takes focus only when asked: typing a letter (above) or a
    791   // click anywhere below the top bar. It is never grabbed on load, on a tab switch
    792   // or on window focus, so the page shortcuts keep the keyboard by default.
    793   // Touch screens are skipped, since focus pops the on-screen keyboard.
    794   const finePointer = window.matchMedia("(pointer: fine)").matches;
    795   const focusPrompt = () => {
    796     if (!section.isConnected || live.hidden || !finePointer) return;
    797     const active = document.activeElement;
    798     // a clicked tab keeps focus after the swap; that still counts as idle
    799     const idle = !active || active === document.body || active === document.documentElement ||
    800       !!active.closest?.("#tabs");
    801     if (idle && document.visibilityState === "visible") input.focus({ preventScroll: true });
    802   };
    803 
    804   for (const [ev, key, target] of [["click", "click", document], ["focus", "winFocus", window], ["reveal:done", "revealed", document]]) {
    805     if (shellHandlers[key]) target.removeEventListener(ev, shellHandlers[key]);
    806   }
    807   shellHandlers.click = (e) => {
    808     if (!section.isConnected || e.button !== 0) return;
    809     if (e.target.closest?.("header, #tabs")) return; // the top bar keeps its own clicks
    810     if (e.target.closest?.("a, button, input, textarea, select, label, summary, [tabindex], #help-overlay")) return;
    811     if (String(window.getSelection?.() || "")) return;
    812     if (window.isLightboxOpen?.()) return;
    813     focusPrompt();
    814   };
    815   shellHandlers.winFocus = null;
    816   shellHandlers.revealed = null;
    817   document.addEventListener("click", shellHandlers.click);
    818 }