bitpull 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bitpull",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "BitPull (bitpull.cloud): share signed, AI-readable code, computing power and open AI models with AI tools through MCP. CLI and MCP server.",
5
5
  "mcpName": "io.github.SamuealPK/bitpull",
6
6
  "bin": {
@@ -12,7 +12,7 @@ function register() {
12
12
  let current = null;
13
13
  const start = () => {
14
14
  if (term || !host || !opts || !opts.folder) return;
15
- term = mountTerminal(host, { cwd: opts.folder, token: opts.token, t: opts.t });
15
+ term = mountTerminal(host, { cwd: opts.folder, token: opts.token, t: opts.t, fontSize: opts.phone ? 12 : 13, keep: !!opts.phone });
16
16
  };
17
17
  ide.terminal.register({
18
18
  mount(container, o) {
@@ -30,6 +30,14 @@ function register() {
30
30
  start();
31
31
  if (term) term.newTab();
32
32
  },
33
+ input(d) {
34
+ start();
35
+ if (term) term.input(d);
36
+ },
37
+ ctrl(on, done) {
38
+ start();
39
+ if (term) term.ctrl(on, done);
40
+ },
33
41
  runVisible(command) {
34
42
  start();
35
43
  current = term ? term.runVisible(command) : null;
package/ui/ide.css CHANGED
@@ -269,3 +269,67 @@ button.sb:hover { background: var(--hover); color: var(--text); }
269
269
 
270
270
  /* Monaco draws long lines under the minimap, whose canvas is slightly transparent: a solid ground hides them. */
271
271
  .monaco-editor .minimap { background: var(--bg); }
272
+
273
+ /* ---- opened from the person's phone: one part at a time, chosen at the bottom, sized for fingers ---- */
274
+ #ide.phone { grid-template-rows: 48px minmax(0, 1fr) auto; font-size: 15px; height: 100%; }
275
+ #ide.phone #statusbar, #ide.phone #toggle-side, #ide.phone #toggle-panel, #ide.phone #toggle-agent, #ide.phone #palette-label,
276
+ #ide.phone .sash, #ide.phone #split-editor, #ide.phone #crumbs, #ide.phone #panel-max, #ide.phone #bottom-close, #ide.phone #views-more,
277
+ #ide.phone #activity button[data-side="files"], #ide.phone table.keys { display: none !important; }
278
+ #ide.phone #titlebar { gap: 4px; padding: 0 4px; }
279
+ #ide.phone button.tb { height: 42px; min-width: 42px; }
280
+ #ide.phone button.tb svg.i { width: 20px; height: 20px; }
281
+ #ide.phone button.tb.folder { flex: 1 1 auto; max-width: none; font-size: 16px; justify-content: flex-start; }
282
+ #ide.phone #titlebar .grow { display: none; }
283
+ #ide.phone button.tb.search { width: 42px; border: 0; background: transparent; justify-content: center; }
284
+ #ide.phone #activity { flex-direction: row; justify-content: flex-start; padding: 0 4px; border-inline-end: 0; border-bottom: 1px solid var(--line); }
285
+ #ide.phone #activity button { width: 56px; height: 48px; }
286
+ #ide.phone #activity button.on::before { top: auto; bottom: 0; left: 10px; right: 10px; width: auto; height: 2px; }
287
+ #ide.phone #side, #ide.phone #agent { border: 0; }
288
+ #ide.phone .view-title { height: 40px; font-size: 12px; padding-inline-start: 16px; }
289
+ #ide.phone .pane-head { height: 44px; font-size: 12px; padding-inline: 8px; }
290
+ #ide.phone .pane-acts { display: inline-flex; gap: 4px; }
291
+ #ide.phone button.ib { width: 40px; height: 40px; }
292
+ #ide.phone button.ib svg.i { width: 20px; height: 20px; }
293
+ #ide.phone ul.tree .item { height: 44px; font-size: 15px; padding-inline-end: 12px; }
294
+ #ide.phone ul.tree .item svg.f { width: 18px; height: 18px; }
295
+ #ide.phone ul.list li { height: 44px; font-size: 15px; }
296
+ #ide.phone :is(input[type=text], input[type=number], select, textarea) { font-size: 16px !important; } /* below 16px iPhone zooms in */
297
+ #ide.phone .field input[type=text] { padding: 8px 4px !important; }
298
+ #ide.phone button.primary, #ide.phone button.ghost { min-height: 42px; padding: 6px 16px; font-size: 15px; }
299
+ #ide.phone #tabs-row { height: 46px; }
300
+ #ide.phone #tabs .tab { padding: 0 4px 0 12px; font-size: 14px; }
301
+ #ide.phone #tabs .tab .x { visibility: visible; width: 36px; height: 36px; }
302
+ #ide.phone .tabs-acts button.ib { width: 44px; height: 44px; }
303
+ #ide.phone .panel-head { height: 46px; padding: 0 4px; }
304
+ #ide.phone .ptabs { overflow-x: auto; scrollbar-width: none; }
305
+ #ide.phone .ptabs button[data-bottom] { height: 42px; font-size: 12px; padding: 0 10px; flex: none; }
306
+ #ide.phone .bp-term-bar { height: 44px !important; }
307
+ #ide.phone .bp-term-bar button.ib { width: 42px !important; height: 40px !important; }
308
+ #ide.phone .bp-term-bar button { font-size: 14px !important; padding: 6px 10px !important; }
309
+ #ide.phone .bp-term-pane { padding: 2px 0 0 4px !important; }
310
+ #ide.phone .agent-top { padding: 8px 10px; }
311
+ #ide.phone ul.list.agents { max-height: 26vh; }
312
+ #ide.phone #agent-log { padding: 10px 12px; font-size: 15px; }
313
+ #ide.phone #composer { margin: 0 8px 8px; padding: 8px; border-radius: 14px; }
314
+ #ide.phone #composer textarea { min-height: 48px; }
315
+ #ide.phone .composer-bar { gap: 6px; flex-wrap: wrap; }
316
+ #ide.phone .composer-bar select { font-size: 15px !important; min-height: 40px; max-width: 40%; }
317
+ #ide.phone button.send { width: 42px; height: 42px; }
318
+ #ide.phone button.send svg.i { width: 20px; height: 20px; }
319
+ #ide.phone .welcome-box h2 { font-size: 18px; }
320
+ #ide.phone ~ .menu li, #ide.phone ~ #menu li { height: 46px; font-size: 15px; }
321
+ #ide.phone ~ #modal { padding-top: 4vh; }
322
+ #ide.phone ~ #modal .box { width: calc(100% - 16px); max-height: 88vh; font-size: 15px; }
323
+ #ide.phone ~ #modal button { min-height: 42px; }
324
+ #ide.phone ~ #tip { display: none !important; } /* no hover on a phone: the tips stay as the buttons' names */
325
+ #phone-nav { display: flex; background: var(--bar); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
326
+ #phone-nav button { flex: 1 1 0; min-width: 0; height: 58px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; background: transparent; border: 0; color: var(--muted); font-size: 11px; }
327
+ #phone-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
328
+ #phone-nav button svg.i { width: 24px; height: 24px; stroke-width: 1.3; }
329
+ #phone-nav button.on { color: var(--bright); }
330
+ #phone-nav button.on svg.i { color: var(--accent); }
331
+ #ide.phone.typing #phone-nav { display: none; }
332
+ #phone-keys { display: flex; gap: 6px; overflow-x: auto; padding: 6px 8px; border-top: 1px solid var(--line); background: var(--side); flex: none; scrollbar-width: none; }
333
+ #phone-keys button { flex: none; min-width: 46px; height: 40px; padding: 0 10px; border: 1px solid var(--line2); border-radius: 8px; background: var(--raised); color: var(--bright); font: 14px ui-monospace, Menlo, Consolas, monospace; }
334
+ #phone-keys button.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
335
+ #ide.phone.typing .agent-top { display: none; }
package/ui/ide.js CHANGED
@@ -5,6 +5,9 @@
5
5
  (function () {
6
6
  const BP = window.__BITPULL__ || {};
7
7
  const token = BP.token || "";
8
+ // Opened from the person's phone (bitpull.cloud/app): one screen at a time, sized for fingers.
9
+ // The computer's own window keeps the editor layout untouched.
10
+ const PHONE = token === "phone";
8
11
  const $ = (id) => document.getElementById(id);
9
12
  // Where the page's files are: /ui/~<build>/, which the webview keeps without asking again.
10
13
  const BASE = ((document.currentScript && document.currentScript.src) || "/ui/ide.js").replace(/ide\.js(\?.*)?$/, "");
@@ -213,7 +216,7 @@
213
216
  register(impl) {
214
217
  terminal = impl;
215
218
  clear($("terminal-host"));
216
- impl.mount($("terminal-host"), { token, folder, lang, t });
219
+ impl.mount($("terminal-host"), { token, folder, lang, t, phone: PHONE });
217
220
  // Its buttons become icons, like the rest of the panel; the words stay as their name.
218
221
  const TERM_ICONS = { "term.new": "plus", "term.split": "split", "term.close": "trash" };
219
222
  const iconize = () => {
@@ -233,6 +236,7 @@
233
236
  get folder() { return folder; },
234
237
  };
235
238
  function showTerminal() {
239
+ if (PHONE) phoneTab = "terminal";
236
240
  showBottom("terminal");
237
241
  if (terminal) terminal.open();
238
242
  else clear($("terminal-host")).append(el("p", { class: "muted pad", text: t("ide.terminal.missing") }));
@@ -245,6 +249,7 @@
245
249
  );
246
250
  const narrow = () => innerWidth < 900;
247
251
  function layout() {
252
+ if (PHONE) return phoneLayout();
248
253
  const L = layoutState;
249
254
  const ws = $("workbench");
250
255
  $("ide").classList.toggle("narrow", narrow());
@@ -305,6 +310,67 @@
305
310
  store.set("layout", L);
306
311
  relayout();
307
312
  }
313
+ // ---- on a phone: one part at a time, chosen with the bar at the bottom ---------------------------------
314
+ let phoneTab = "files";
315
+ let phoneMore = "git";
316
+ const PHONE_PARTS = { files: "side", more: "side", editor: "editor-area", terminal: "panel", agent: "agent" };
317
+ function phoneLayout() {
318
+ const ws = $("workbench");
319
+ $("ide").classList.add("phone");
320
+ const more = phoneTab === "more";
321
+ ws.style.gridTemplateColumns = "minmax(0, 1fr)";
322
+ ws.style.gridTemplateAreas = more ? '"act" "main"' : '"main"';
323
+ ws.style.gridTemplateRows = more ? "auto minmax(0, 1fr)" : "minmax(0, 1fr)";
324
+ $("activity").hidden = !more;
325
+ for (const id of ["side", "editor-area", "panel", "agent"]) {
326
+ $(id).style.gridArea = "main";
327
+ $(id).hidden = PHONE_PARTS[phoneTab] !== id;
328
+ }
329
+ for (const sash of ws.querySelectorAll(".sash")) sash.hidden = true;
330
+ if ($("phone-keys")) $("phone-keys").hidden = !(phoneTab === "terminal" && currentBottom() === "terminal");
331
+ for (const b of document.querySelectorAll("#phone-nav button")) b.classList.toggle("on", b.dataset.tab === phoneTab);
332
+ for (const p of document.querySelectorAll(".pane")) p.classList.toggle("open", !!layoutState.panes[p.dataset.pane]);
333
+ relayout();
334
+ }
335
+ function phoneShow(tab) {
336
+ phoneTab = tab;
337
+ if (tab === "files") showSide("files");
338
+ else if (tab === "more") showSide(phoneMore);
339
+ else if (tab === "terminal") showTerminal();
340
+ else phoneLayout();
341
+ }
342
+ function phoneSetup() {
343
+ const nav = el("nav", { id: "phone-nav" });
344
+ for (const [tab, ic, label] of [["files", "files", "phone.files"], ["editor", "output", "phone.editor"], ["terminal", "terminal", "pj.tab.terminal"], ["agent", "logo", "phone.agent"], ["more", "more", "phone.more"]]) {
345
+ nav.append(el("button", { type: "button", "data-tab": tab, "data-tip": `tip.phone.${tab}`, "aria-label": t(label), onclick: () => phoneShow(tab) }, icon(ic), el("span", { text: t(label) })));
346
+ }
347
+ $("ide").append(nav);
348
+ // Keys a phone's keyboard lacks, for the terminal. Pressing them keeps the keyboard open.
349
+ const keys = el("div", { id: "phone-keys", hidden: true });
350
+ const KEYS = [["Esc", "\x1b"], ["Tab", "\t"], ["Ctrl", null], ["↑", "\x1b[A"], ["↓", "\x1b[B"], ["←", "\x1b[D"], ["→", "\x1b[C"], ["Ctrl+C", "\x03"], ["|", "|"], ["/", "/"], ["~", "~"], ["-", "-"]];
351
+ for (const [label, seq] of KEYS) {
352
+ const b = el("button", { type: "button", text: label, "data-tip": seq === null ? "tip.phone.ctrl" : "tip.phone.key", onpointerdown: (e) => e.preventDefault() });
353
+ b.addEventListener("click", () => {
354
+ if (!terminal) return;
355
+ if (seq === null) {
356
+ const on = !b.classList.contains("on");
357
+ b.classList.toggle("on", on);
358
+ if (terminal.ctrl) terminal.ctrl(on, () => b.classList.remove("on"));
359
+ } else if (terminal.input) terminal.input(seq);
360
+ });
361
+ keys.append(b);
362
+ }
363
+ $("panel").append(keys);
364
+ // With the keyboard open the page gets much shorter (the phone page fits it to what stays visible):
365
+ // then the bar steps aside to leave room, and the terminal's keys stay.
366
+ let tallest = innerHeight;
367
+ const keyboard = () => {
368
+ tallest = Math.max(tallest, innerHeight);
369
+ $("ide").classList.toggle("typing", innerHeight < tallest * 0.75);
370
+ };
371
+ addEventListener("resize", keyboard);
372
+ addEventListener("orientationchange", () => { tallest = 0; setTimeout(keyboard, 300); });
373
+ }
308
374
  /** Monaco and the terminal measure themselves after the grid has settled. */
309
375
  function relayout() {
310
376
  requestAnimationFrame(() => {
@@ -347,6 +413,7 @@
347
413
  $("panel-max").addEventListener("click", () => toggle("panelMax"));
348
414
 
349
415
  function showSide(name) {
416
+ if (PHONE) { if (name !== "files") phoneMore = name; phoneTab = name === "files" ? "files" : "more"; }
350
417
  layoutState.sideOpen = true;
351
418
  layoutState.sideView = name;
352
419
  for (const b of document.querySelectorAll("#activity button[data-side]")) b.classList.toggle("on", b.dataset.side === name);
@@ -449,7 +516,9 @@
449
516
  monaco.editor.defineTheme("bitpull-light", { base: "vs", inherit: true, rules: [], colors: { "editor.background": "#ffffff", "editorLineNumber.foreground": "#6e7681" } });
450
517
  }
451
518
  const theme = () => (dark() ? "bitpull-dark" : "bitpull-light");
452
- const EDITOR_OPTS = () => ({ automaticLayout: false, theme: theme(), minimap: { enabled: true, renderCharacters: false, scale: 1 }, fontSize: 13, lineHeight: 20, fontFamily: 'Menlo, Monaco, "Cascadia Code", Consolas, "Courier New", monospace', tabSize: 2, renderWhitespace: "selection", smoothScrolling: true, scrollBeyondLastLine: false, fixedOverflowWidgets: true, padding: { top: 4 }, stickyScroll: { enabled: true }, bracketPairColorization: { enabled: true }, guides: { bracketPairs: "active", indentation: true } });
519
+ const EDITOR_OPTS = () => ({ automaticLayout: false, theme: theme(), minimap: { enabled: true, renderCharacters: false, scale: 1 }, fontSize: 13, lineHeight: 20, fontFamily: 'Menlo, Monaco, "Cascadia Code", Consolas, "Courier New", monospace', tabSize: 2, renderWhitespace: "selection", smoothScrolling: true, scrollBeyondLastLine: false, fixedOverflowWidgets: true, padding: { top: 4 }, stickyScroll: { enabled: true }, bracketPairColorization: { enabled: true }, guides: { bracketPairs: "active", indentation: true },
520
+ // A phone's screen: no minimap, long lines wrap, a size readable without zooming.
521
+ ...(PHONE ? { minimap: { enabled: false }, wordWrap: "on", fontSize: 14, lineHeight: 21, stickyScroll: { enabled: false }, folding: false, glyphMargin: false, lineNumbersMinChars: 3, lineDecorationsWidth: 6, scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 } } : {}) });
453
522
  function wireEditor(ed) {
454
523
  ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveActive());
455
524
  ed.addAction({ id: "bitpull.inline", label: t("ide.inline"), keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], contextMenuGroupId: "1_modification", run: () => inlineEdit() });
@@ -590,6 +659,8 @@
590
659
  if (r.secret) output(t("ide.secret_open", { path }));
591
660
  }
592
661
  activate(tab);
662
+ // On a phone a file chosen in the list, a search result or a problem opens on the editor's screen.
663
+ if (PHONE && phoneTab !== "editor" && phoneTab !== "agent") { phoneTab = "editor"; phoneLayout(); }
593
664
  const ed = focused || editor;
594
665
  if (line) { ed.revealLineInCenter(line); ed.setPosition({ lineNumber: line, column: 1 }); ed.focus(); }
595
666
  }
@@ -1330,8 +1401,9 @@
1330
1401
  (async () => {
1331
1402
  drawIcons(document);
1332
1403
  await loadWords();
1404
+ if (PHONE) phoneSetup();
1333
1405
  $("ide").hidden = false;
1334
- $("back").addEventListener("click", () => { location.href = `/#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`; });
1406
+ $("back").addEventListener("click", () => { location.href = `${PHONE ? "/remote/" : "/"}#token=${encodeURIComponent(token)}&lang=${encodeURIComponent(lang)}`; });
1335
1407
  for (const b of document.querySelectorAll("#activity button[data-side]")) b.classList.toggle("on", b.dataset.side === layoutState.sideView && layoutState.sideOpen);
1336
1408
  for (const p of document.querySelectorAll("#side > section")) p.hidden = p.dataset.panel !== layoutState.sideView;
1337
1409
  layout();
@@ -1339,6 +1411,7 @@
1339
1411
  renderModeHint();
1340
1412
  renderProblemCounts();
1341
1413
  showBottom(store.get("bottom", "problems") === "terminal" ? "problems" : store.get("bottom", "problems"));
1414
+ if (PHONE) phoneShow("files");
1342
1415
  // Folders and models while the editor loads: the tree shows before Monaco is ready.
1343
1416
  const lists = Promise.all([loadModels().catch((err) => output(explain(err))), loadFolders().catch((err) => output(explain(err)))]).then(() => ($("folder").value ? setFolder($("folder").value) : null));
1344
1417
  try { monaco = await loadMonaco(); setupEditor(); } catch (err) { output(t("ide.err.editor_missing")); }
package/ui/terminal.js CHANGED
@@ -88,11 +88,25 @@ export function mountTerminalSetting(el, { token, t = (k) => k, onChange } = {})
88
88
  terminalStatus(token).then(render, () => render({ enabled: false, available: false }));
89
89
  }
90
90
 
91
+ /** This phone's name for its shells on the computer: the same every time the page opens. */
92
+ function keptName() {
93
+ try {
94
+ let n = localStorage.getItem("bitpull_term_keep");
95
+ if (!n || !/^[A-Za-z0-9_-]{16,64}$/.test(n)) {
96
+ n = Array.from(crypto.getRandomValues(new Uint8Array(18)), (b) => b.toString(16).padStart(2, "0")).join("");
97
+ localStorage.setItem("bitpull_term_keep", n);
98
+ }
99
+ return n;
100
+ } catch {
101
+ return Array.from(crypto.getRandomValues(new Uint8Array(18)), (b) => b.toString(16).padStart(2, "0")).join("");
102
+ }
103
+ }
104
+
91
105
  /**
92
106
  * Mounts the terminal in `el`. `cwd` must be a folder the person authorised; `t` translates.
93
107
  * Returns the controls the page and the Projects view use.
94
108
  */
95
- export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
109
+ export function mountTerminal(el, { cwd, token, t = (k) => k, fontSize = 13, keep = false } = {}) {
96
110
  const ended = (code) => String(t("term.exited", { code })).replace("{code}", String(code));
97
111
  addCss();
98
112
  el.replaceChildren();
@@ -117,6 +131,8 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
117
131
  el.appendChild(root);
118
132
 
119
133
  const tabs = []; // { title, panes: [{ term, fit, id?, box, readonly }] }
134
+ let ctrlNext = false;
135
+ let ctrlDone = null;
120
136
  let active = null;
121
137
  let ws = null;
122
138
  let ready = false;
@@ -124,6 +140,9 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
124
140
  const waiting = new Map();
125
141
  let nextRef = 1;
126
142
  const byId = new Map();
143
+ // On a phone the shells wait on the computer for an hour when the phone goes away, under this name.
144
+ const keepName = keep ? keptName() : null;
145
+ let firstReady = true;
127
146
 
128
147
  const message = (key, withEnable) => {
129
148
  panesEl.replaceChildren();
@@ -179,7 +198,7 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
179
198
  const makePane = (readonly) => {
180
199
  const box = document.createElement("div");
181
200
  box.className = "bp-term-pane";
182
- const term = new Terminal({ cursorBlink: !readonly, disableStdin: readonly, fontSize: 13, fontFamily: "ui-monospace, Menlo, Consolas, monospace", theme: theme(), scrollback: 5000, convertEol: readonly });
201
+ const term = new Terminal({ cursorBlink: !readonly, disableStdin: readonly, fontSize, fontFamily: "ui-monospace, Menlo, Consolas, monospace", theme: theme(), scrollback: 5000, convertEol: readonly });
183
202
  const fit = new FitAddon();
184
203
  term.loadAddon(fit);
185
204
  term.open(box);
@@ -199,12 +218,24 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
199
218
  return { term, fit, box, readonly };
200
219
  };
201
220
 
202
- const startShell = (pane) => {
203
- pane.ref = nextRef++;
204
- pane.go = () => ws.send(JSON.stringify({ t: "open", ref: pane.ref, cwd, cols: pane.term.cols, rows: pane.term.rows }));
205
- waiting.set(pane.ref, pane);
206
- if (ready) pane.go();
221
+ const startShell = (pane, existing) => {
222
+ if (existing) {
223
+ pane.id = existing;
224
+ byId.set(existing, pane);
225
+ } else {
226
+ pane.ref = nextRef++;
227
+ pane.go = () => ws.send(JSON.stringify({ t: "open", ref: pane.ref, cwd, cols: pane.term.cols, rows: pane.term.rows }));
228
+ waiting.set(pane.ref, pane);
229
+ if (ready) pane.go();
230
+ }
207
231
  pane.term.onData((d) => {
232
+ // "Ctrl" pressed on a phone's extra keys: the next letter becomes Ctrl+letter.
233
+ if (ctrlNext && d.length === 1) {
234
+ const c = d.toUpperCase().charCodeAt(0);
235
+ if (c >= 64 && c <= 95) d = String.fromCharCode(c - 64);
236
+ ctrlNext = null;
237
+ if (ctrlDone) ctrlDone();
238
+ }
208
239
  if (!pane.id || !ready) return;
209
240
  // Huge pastes go in pieces: a message has a size limit.
210
241
  for (let i = 0; i < d.length; i += MAX_INPUT) ws.send(JSON.stringify({ t: "in", id: pane.id, d: d.slice(i, i + MAX_INPUT) }));
@@ -215,7 +246,7 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
215
246
  if (ws && ws.readyState <= 1) return;
216
247
  ready = false;
217
248
  ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/api/terminal`);
218
- ws.onopen = () => ws.send(JSON.stringify({ t: "auth", token }));
249
+ ws.onopen = () => ws.send(JSON.stringify({ t: "auth", token, ...(keepName ? { keep: keepName } : {}) }));
219
250
  ws.onmessage = (e) => {
220
251
  let m;
221
252
  try {
@@ -225,7 +256,14 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
225
256
  }
226
257
  if (m.t === "ready") {
227
258
  ready = true;
259
+ if (keepName) pickUp(m.kept || []);
228
260
  for (const p of waiting.values()) p.go();
261
+ if (keepName && firstReady && !tabs.length) newTab();
262
+ firstReady = false;
263
+ } else if (m.t === "attached") {
264
+ // The computer sends the screen again as it is now: it replaces what this page still showed.
265
+ byId.get(m.id)?.term.reset();
266
+ fitAll();
229
267
  } else if (m.t === "opened") {
230
268
  const p = waiting.get(m.ref);
231
269
  if (!p) return;
@@ -241,6 +279,14 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
241
279
  byId.delete(m.id);
242
280
  p.id = undefined;
243
281
  }
282
+ } else if (m.t === "error" && m.error === "no_terminal" && typeof m.id === "number") {
283
+ // Gone while the phone was away (more than an hour, or the computer restarted).
284
+ const p = byId.get(m.id);
285
+ if (p) {
286
+ byId.delete(m.id);
287
+ p.id = undefined;
288
+ p.term.write(`\r\n\x1b[2m${t("term.gone")}\x1b[0m\r\n`);
289
+ }
244
290
  } else if (m.t === "error") {
245
291
  const p = waiting.get(m.ref);
246
292
  waiting.delete(m.ref);
@@ -251,6 +297,8 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
251
297
  ws.onclose = (e) => {
252
298
  ready = false;
253
299
  if (disposed) return;
300
+ // A phone's connection comes back by itself: its shells are still there.
301
+ if (keepName && e.code !== 4409 && e.code !== 4410 && e.code !== 4401) setTimeout(() => !disposed && connect(), 2000);
254
302
  if (e.code === 4409 || e.code === 1006) {
255
303
  terminalStatus(token).then((s) => {
256
304
  if (!s.available) message("term.unavailable", false);
@@ -260,6 +308,20 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
260
308
  };
261
309
  };
262
310
 
311
+ /** Shows again the shells this phone left running: those of this page first, then the others in this folder. */
312
+ function pickUp(kept) {
313
+ for (const p of byId.values()) ws.send(JSON.stringify({ t: "attach", id: p.id, cols: p.term.cols, rows: p.term.rows }));
314
+ for (const k of kept) {
315
+ if (byId.has(k.id) || k.cwd !== cwd) continue;
316
+ const pane = makePane(false);
317
+ const tab = { title: "", panes: [pane] };
318
+ tabs.push(tab);
319
+ show(tab);
320
+ startShell(pane, k.id);
321
+ ws.send(JSON.stringify({ t: "attach", id: k.id, cols: pane.term.cols, rows: pane.term.rows }));
322
+ }
323
+ }
324
+
263
325
  function newTab() {
264
326
  const pane = makePane(false);
265
327
  const tab = { title: "", panes: [pane] };
@@ -328,7 +390,7 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
328
390
  bar.append(tabsEl, button("term.new", "tip.term.new", () => newTab()), button("term.split", "tip.term.split", () => split()), button("term.close", "tip.term.close", () => close()));
329
391
  const resize = new ResizeObserver(() => fitAll());
330
392
  resize.observe(panesEl);
331
- // Closing the page ends its shells: the server closes them with the connection too.
393
+ // Closing the page ends its shells: the server closes them with the connection too. A phone's wait an hour.
332
394
  window.addEventListener("pagehide", dispose);
333
395
 
334
396
  terminalStatus(token).then(
@@ -337,10 +399,26 @@ export function mountTerminal(el, { cwd, token, t = (k) => k } = {}) {
337
399
  if (!s.available) return message("term.unavailable", false);
338
400
  if (!s.enabled) return message("term.off", true);
339
401
  connect();
340
- newTab();
402
+ // On a phone, the first answer says whether shells are waiting; a new one opens only if none is.
403
+ if (!keepName) newTab();
341
404
  },
342
405
  () => message("term.failed", false),
343
406
  );
344
407
 
345
- return { newTab, split, close, runVisible, dispose, fit: fitAll };
408
+ /** Keys from outside the terminal (a phone's extra keys): typed into the shell shown now. */
409
+ function input(d) {
410
+ const p = active && !active.readonly ? active.panes[active.panes.length - 1] : null;
411
+ if (!p) return;
412
+ if (p.id && ready) ws.send(JSON.stringify({ t: "in", id: p.id, d: String(d).slice(0, MAX_INPUT) }));
413
+ p.term.focus();
414
+ }
415
+ /** Makes the next letter typed a Ctrl+letter; done() runs once it is used. */
416
+ function ctrl(on, done) {
417
+ ctrlNext = !!on;
418
+ ctrlDone = on ? done : null;
419
+ const p = active && active.panes[active.panes.length - 1];
420
+ if (p) p.term.focus();
421
+ }
422
+
423
+ return { newTab, split, close, runVisible, dispose, fit: fitAll, input, ctrl };
346
424
  }