@songsid/agend 2.3.5-alpha.2 → 2.3.5-alpha.3

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.
Files changed (212) hide show
  1. package/dist/agend-home.d.ts +13 -0
  2. package/dist/agend-home.js +42 -0
  3. package/dist/agend-home.js.map +1 -1
  4. package/dist/auth-api.d.ts +21 -0
  5. package/dist/auth-api.js +91 -4
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +12 -2
  8. package/dist/authority/web-authority.js +25 -4
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +16 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +11 -1
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-identity.d.ts +2 -0
  15. package/dist/backend/kiro-identity.js +13 -6
  16. package/dist/backend/kiro-identity.js.map +1 -1
  17. package/dist/backend/kiro.js +18 -3
  18. package/dist/backend/kiro.js.map +1 -1
  19. package/dist/backend-availability.d.ts +6 -0
  20. package/dist/backend-availability.js +13 -5
  21. package/dist/backend-availability.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +1 -1
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/cli.js +6 -9
  25. package/dist/cli.js.map +1 -1
  26. package/dist/config-validator.js +12 -1
  27. package/dist/config-validator.js.map +1 -1
  28. package/dist/daemon.d.ts +5 -0
  29. package/dist/daemon.js +107 -30
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +143 -8
  32. package/dist/fleet-manager.js +874 -166
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/fleet-windows-runtime.js +7 -0
  35. package/dist/fleet-windows-runtime.js.map +1 -1
  36. package/dist/fleet-yaml-patch.d.ts +3 -0
  37. package/dist/fleet-yaml-patch.js +103 -0
  38. package/dist/fleet-yaml-patch.js.map +1 -0
  39. package/dist/general-binding.d.ts +85 -0
  40. package/dist/general-binding.js +298 -0
  41. package/dist/general-binding.js.map +1 -0
  42. package/dist/general-host-owner.d.ts +8 -0
  43. package/dist/general-host-owner.js +26 -0
  44. package/dist/general-host-owner.js.map +1 -0
  45. package/dist/general-instance.d.ts +7 -0
  46. package/dist/general-instance.js +20 -3
  47. package/dist/general-instance.js.map +1 -1
  48. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  49. package/dist/general-role.d.ts +5 -0
  50. package/dist/general-role.js +26 -0
  51. package/dist/general-role.js.map +1 -0
  52. package/dist/instance-lifecycle.d.ts +1 -0
  53. package/dist/instance-lifecycle.js +26 -4
  54. package/dist/instance-lifecycle.js.map +1 -1
  55. package/dist/instructions.d.ts +2 -0
  56. package/dist/instructions.js +6 -0
  57. package/dist/instructions.js.map +1 -1
  58. package/dist/kiro-resume-summary.d.ts +37 -0
  59. package/dist/kiro-resume-summary.js +85 -0
  60. package/dist/kiro-resume-summary.js.map +1 -0
  61. package/dist/locale.js +59 -0
  62. package/dist/locale.js.map +1 -1
  63. package/dist/login-controller.js +9 -1
  64. package/dist/login-controller.js.map +1 -1
  65. package/dist/native-pane-lifecycle.js +4 -1
  66. package/dist/native-pane-lifecycle.js.map +1 -1
  67. package/dist/native-pane-manager.d.ts +1 -0
  68. package/dist/native-pane-manager.js +23 -2
  69. package/dist/native-pane-manager.js.map +1 -1
  70. package/dist/needs-you-hub.js +1 -1
  71. package/dist/needs-you-hub.js.map +1 -1
  72. package/dist/needs-you.d.ts +8 -1
  73. package/dist/needs-you.js +5 -2
  74. package/dist/needs-you.js.map +1 -1
  75. package/dist/package-preimage.js +27 -2
  76. package/dist/package-preimage.js.map +1 -1
  77. package/dist/pane-port.d.ts +11 -0
  78. package/dist/public-web-gateway.js +9 -0
  79. package/dist/public-web-gateway.js.map +1 -1
  80. package/dist/public-web-link.d.ts +5 -0
  81. package/dist/public-web-link.js +4 -0
  82. package/dist/public-web-link.js.map +1 -1
  83. package/dist/quickstart-api.js +9 -5
  84. package/dist/quickstart-api.js.map +1 -1
  85. package/dist/quickstart.d.ts +1 -0
  86. package/dist/quickstart.js +4 -1
  87. package/dist/quickstart.js.map +1 -1
  88. package/dist/reply-buttons-controller.js +10 -0
  89. package/dist/reply-buttons-controller.js.map +1 -1
  90. package/dist/runtime-bundle.js +3 -0
  91. package/dist/runtime-bundle.js.map +1 -1
  92. package/dist/runtime-general-protocol.d.ts +22 -0
  93. package/dist/runtime-general-protocol.js +57 -0
  94. package/dist/runtime-general-protocol.js.map +1 -0
  95. package/dist/runtime-general-workspace.d.ts +17 -0
  96. package/dist/runtime-general-workspace.js +213 -0
  97. package/dist/runtime-general-workspace.js.map +1 -0
  98. package/dist/runtime-pane-assets.d.ts +1 -1
  99. package/dist/runtime-pane-assets.js +1 -1
  100. package/dist/runtime-pane-assets.js.map +1 -1
  101. package/dist/runtime-pane-host.d.ts +1 -0
  102. package/dist/runtime-pane-host.js +22 -1
  103. package/dist/runtime-pane-host.js.map +1 -1
  104. package/dist/runtime-pane-mcp.d.ts +8 -0
  105. package/dist/runtime-pane-mcp.js +15 -2
  106. package/dist/runtime-pane-mcp.js.map +1 -1
  107. package/dist/runtime-pane-port.d.ts +6 -1
  108. package/dist/runtime-pane-port.js +21 -0
  109. package/dist/runtime-pane-port.js.map +1 -1
  110. package/dist/runtime-pane-protocol.d.ts +4 -1
  111. package/dist/runtime-pane-protocol.js +7 -1
  112. package/dist/runtime-pane-protocol.js.map +1 -1
  113. package/dist/settings-api.d.ts +5 -0
  114. package/dist/settings-api.js +37 -1
  115. package/dist/settings-api.js.map +1 -1
  116. package/dist/settings-baseline.d.ts +1 -0
  117. package/dist/settings-baseline.js +1 -1
  118. package/dist/settings-baseline.js.map +1 -1
  119. package/dist/settings-confirmation.d.ts +1 -0
  120. package/dist/settings-confirmation.js +1 -0
  121. package/dist/settings-confirmation.js.map +1 -1
  122. package/dist/settings-effect.d.ts +4 -0
  123. package/dist/settings-effect.js +35 -0
  124. package/dist/settings-effect.js.map +1 -1
  125. package/dist/settings-http-confirmation.d.ts +1 -0
  126. package/dist/settings-http-confirmation.js +4 -0
  127. package/dist/settings-http-confirmation.js.map +1 -1
  128. package/dist/settings-request-capability.d.ts +2 -0
  129. package/dist/settings-request-capability.js +4 -0
  130. package/dist/settings-request-capability.js.map +1 -1
  131. package/dist/settings-route-lifetime.d.ts +14 -0
  132. package/dist/settings-route-lifetime.js +48 -0
  133. package/dist/settings-route-lifetime.js.map +1 -0
  134. package/dist/settings-transaction.d.ts +4 -0
  135. package/dist/settings-transaction.js +9 -0
  136. package/dist/settings-transaction.js.map +1 -1
  137. package/dist/setup-form.js +6 -3
  138. package/dist/setup-form.js.map +1 -1
  139. package/dist/setup-i18n.js +3 -0
  140. package/dist/setup-i18n.js.map +1 -1
  141. package/dist/stall-reporter.d.ts +21 -0
  142. package/dist/stall-reporter.js +82 -0
  143. package/dist/stall-reporter.js.map +1 -0
  144. package/dist/tmux-control.d.ts +2 -2
  145. package/dist/tmux-control.js +4 -2
  146. package/dist/tmux-control.js.map +1 -1
  147. package/dist/tmux-exec-helper.d.ts +6 -3
  148. package/dist/tmux-exec-helper.js +115 -53
  149. package/dist/tmux-exec-helper.js.map +1 -1
  150. package/dist/tool-permissions.js +2 -1
  151. package/dist/tool-permissions.js.map +1 -1
  152. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  153. package/dist/tunnel/cloudflared-install.js +20 -1
  154. package/dist/tunnel/cloudflared-install.js.map +1 -1
  155. package/dist/tunnel/cloudflared.d.ts +8 -1
  156. package/dist/tunnel/cloudflared.js +34 -6
  157. package/dist/tunnel/cloudflared.js.map +1 -1
  158. package/dist/tunnel/lease.d.ts +3 -3
  159. package/dist/tunnel/lease.js +23 -19
  160. package/dist/tunnel/lease.js.map +1 -1
  161. package/dist/tunnel/manager.js +6 -7
  162. package/dist/tunnel/manager.js.map +1 -1
  163. package/dist/types.d.ts +2 -0
  164. package/dist/ui/agent-proposals.js +2 -2
  165. package/dist/ui/general-location.js +71 -0
  166. package/dist/ui/native-pane-recovery.js +1 -1
  167. package/dist/ui/panel-chat.js +2 -0
  168. package/dist/ui/panel-needs.js +2 -0
  169. package/dist/ui/panel-settings.js +5 -1
  170. package/dist/ui/settings-dialogs.js +8 -2
  171. package/dist/ui/settings-model.js +2 -1
  172. package/dist/ui/settings-outside.js +176 -0
  173. package/dist/ui/settings-registry.js +1 -0
  174. package/dist/ui/settings-strings.js +2 -0
  175. package/dist/ui/shared/app-i18n.js +2 -2
  176. package/dist/ui/shared/app.css +16 -1
  177. package/dist/ui/shared/instance-nav.js +13 -3
  178. package/dist/ui/shared/panel-view.js +266 -17
  179. package/dist/ui/shared/qrcode.module.js +2237 -0
  180. package/dist/ui/shared/signin.js +39 -5
  181. package/dist/ui/shared/vendor/qrcode-generator.LICENSE +27 -0
  182. package/dist/ui/shared/vendor/vendor.json +19 -2
  183. package/dist/ui/shared/view-strings.js +28 -0
  184. package/dist/unattended-install.d.ts +1 -1
  185. package/dist/unattended-install.js +19 -5
  186. package/dist/unattended-install.js.map +1 -1
  187. package/dist/update-check.d.ts +2 -8
  188. package/dist/update-check.js +2 -5
  189. package/dist/update-check.js.map +1 -1
  190. package/dist/view-api.d.ts +15 -0
  191. package/dist/view-api.js +94 -33
  192. package/dist/view-api.js.map +1 -1
  193. package/dist/web-api.d.ts +12 -0
  194. package/dist/web-api.js +77 -0
  195. package/dist/web-api.js.map +1 -1
  196. package/dist/web-login.d.ts +4 -3
  197. package/dist/web-login.js +23 -14
  198. package/dist/web-login.js.map +1 -1
  199. package/dist/web-only-fleet.d.ts +20 -0
  200. package/dist/web-only-fleet.js +21 -0
  201. package/dist/web-only-fleet.js.map +1 -1
  202. package/dist/web-remote-access.d.ts +152 -0
  203. package/dist/web-remote-access.js +368 -0
  204. package/dist/web-remote-access.js.map +1 -0
  205. package/dist/web-session.d.ts +12 -0
  206. package/dist/web-session.js +22 -8
  207. package/dist/web-session.js.map +1 -1
  208. package/dist/windows-agents.d.ts +12 -0
  209. package/dist/windows-agents.js +24 -0
  210. package/dist/windows-agents.js.map +1 -1
  211. package/launcher/agend-install.cjs +16 -2
  212. package/package.json +1 -1
@@ -26,7 +26,7 @@ import { viewStore } from "./view-roster-store.js";
26
26
  /** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
27
27
  const fleetUnreachable = () => appStore.get().connection === "reconnecting";
28
28
  import { navigate } from "./app-nav.js";
29
- import { viewPath } from "./app-route.js";
29
+ import { logPath, sectionPath, viewPath } from "./app-route.js";
30
30
  import { Dialog } from "./ui-dialog.js";
31
31
  import { Empty, ErrorState, Skeleton } from "./ui-states.js";
32
32
  import { Icon } from "./ui-icons.js";
@@ -41,6 +41,44 @@ function tn(key, values = {}) {
41
41
  for (const [k, v] of Object.entries(values)) s = s.split(`{${k}}`).join(String(v));
42
42
  return s;
43
43
  }
44
+ /**
45
+ * v2.3.4-beta.4 P1 (hmsg-985db235, hmsg-5ab1997d): this module is loaded on first use, so a tab opened before an upgrade
46
+ * runs the new View with the stylesheet and the View's words it loaded at its start. Without the View's layout rules
47
+ * (.v-term-box) the terminal box grew with its text: the text shrank every second (fit() measured the box its own text
48
+ * sized), a normal-buffer CLI's View showed its whole history from the top, and new words showed as raw keys. Each is
49
+ * found by a mark the current files carry — VIEW_REV in app.css (the --v-term-rev property) and view-strings.js (the
50
+ * view.rev word) — and a stale one is read again, once per page: the stylesheet re-linked after the one in force, the
51
+ * words imported again (register() replaces them). The query only makes it a new request: /assets/ serves the file.
52
+ */
53
+ export const VIEW_REV = "2";
54
+ const healed = { css: false, words: false };
55
+ export function healStalePage(doc = typeof document !== "undefined" ? document : null, load = (u) => import(u), onWords = () => {}) {
56
+ if (!doc) return { css: false, words: false };
57
+ const out = { css: false, words: false };
58
+ try {
59
+ const box = doc.querySelector && doc.querySelector(".v-term-box");
60
+ // Only a page that has the stylesheet linked (the app; never a test's page without one) and an older one in force.
61
+ const linked = [...(doc.querySelectorAll ? doc.querySelectorAll('link[rel="stylesheet"]') : [])]
62
+ .some((l) => String(l.getAttribute("href") || "").split("?")[0] === "/assets/app.css");
63
+ if (box && linked && !healed.css) {
64
+ const cs = typeof getComputedStyle === "function" ? getComputedStyle(box) : null;
65
+ const rev = cs && typeof cs.getPropertyValue === "function" ? String(cs.getPropertyValue("--v-term-rev")).trim().replace(/^"|"$/g, "") : VIEW_REV;
66
+ if (rev !== VIEW_REV) {
67
+ healed.css = true; out.css = true;
68
+ const link = doc.createElement("link");
69
+ link.setAttribute("rel", "stylesheet"); link.setAttribute("href", `/assets/app.css?rev=${VIEW_REV}-${Date.now().toString(36)}`);
70
+ doc.head.appendChild(link);
71
+ }
72
+ }
73
+ } catch { /* never in the View's way */ }
74
+ if (t("view.rev") !== VIEW_REV && !healed.words) {
75
+ healed.words = true; out.words = true;
76
+ Promise.resolve(load(`./view-strings.js?rev=${VIEW_REV}-${Date.now().toString(36)}`)).then(() => onWords(), () => {});
77
+ }
78
+ return out;
79
+ }
80
+ /** For tests: as on a fresh page. */
81
+ export function resetHealed() { healed.css = false; healed.words = false; }
44
82
  const stored = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
45
83
  const store = (k, v) => { try { localStorage.setItem(k, v); } catch { /* this page only */ } };
46
84
 
@@ -198,8 +236,8 @@ export function ViewPanel({ route, navKey }) {
198
236
  else if (!it) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="alert" title=${tn("notFound", { name })} hint=${tn("notFoundHint")} /></div>`;
199
237
  else if (guest && !trusted) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="view" title=${tn("guestNeedsTrusted")} hint=${tn("guestNeedsTrustedHint")} /></div>`;
200
238
  else if (guest) body = html`<p data-trust="panel-content" class="v-watching" role="note">${tn("guestWatching")}</p>
201
- <${Terminal} key=${name} name=${name} lease=${lease} density=${density} every=${GUEST_PANE_MS} />`;
202
- else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} />
239
+ <${Terminal} key=${name} name=${name} lease=${lease} density=${density} every=${GUEST_PANE_MS} backend=${it.backend || ""} signedIn=${false} />`;
240
+ else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} backend=${it.backend || ""} signedIn=${!viewOnly} />
203
241
  <${ViewIdentity} it=${it} viewOnly=${viewOnly} onEdit=${() => setDialog("edit")} />`;
204
242
 
205
243
  return html`<div data-effect-panel="view" class="panel p-view">
@@ -214,11 +252,74 @@ export function ViewPanel({ route, navKey }) {
214
252
 
215
253
  /** #2855: how often a guest's View reads the pane (the server allows it two reads a second). */
216
254
  const GUEST_PANE_MS = 1000;
217
- /** The live terminal: the pane every 800 ms (passive), drawn as escaped markup, its font fitted to the pane's grid. */
218
- function Terminal({ name, lease, density, every = 800 }) {
255
+ /**
256
+ * The live terminal: the pane every 800 ms (passive), drawn as escaped markup, its font fitted to the pane's grid.
257
+ *
258
+ * M-2262 (the user, hmsg-435f6df6): scrolling back. Following (the default), it shows the pane's screen as it is now.
259
+ * Scrolling up — the wheel, a touch drag, PageUp/↑/Home, or "Earlier output" — reads the pane's scrollback once
260
+ * (`?history=1`: tmux's history above the screen, bounded) and shows it: reading. While reading, nothing moves the
261
+ * person: the refresh keeps reading the screen only to say "New output below", and never redraws. "Jump to latest"
262
+ * (or End), or scrolling to the bottom, follows again. Everything happens in this page, over text tmux was asked to
263
+ * capture: never copy-mode and never a key to the pane (hmsg-b8a170c0 — scrolling must never affect delivery).
264
+ */
265
+ /** How long a scrollback note stays (it covers the terminal's bottom rows). */
266
+ const NOTE_MS = 6000;
267
+ /** A scroll this soon after the person's own wheel, touch, key or pointer is theirs (not one this page made). */
268
+ const GESTURE_MS = 1000;
269
+ /**
270
+ * #2969 review (Prism P3): is `live` (a screen read) still what `shown` ends with — the screen as it was when reading
271
+ * began (the scrollback's last lines, or the screen itself)? Trailing blank rows are not content.
272
+ */
273
+ export function sameScreen(shown, live) {
274
+ if (shown == null || live == null) return shown === live;
275
+ const trim = (t) => String(t).replace(/\n+$/, "");
276
+ const s = trim(shown).split("\n"), l = trim(live).split("\n");
277
+ return s.length >= l.length && s.slice(s.length - l.length).join("\n") === l.join("\n");
278
+ }
279
+ /** M-2262 survey: CLIs that redraw their whole window (no tmux scrollback even outside the alternate screen). */
280
+ const REDRAWS_IN_PLACE = new Set(["grok"]);
281
+ /** M-2262 survey: the full-screen CLIs (the alternate screen, or redrawn in place) — tmux keeps nothing above their screen. */
282
+ const FULL_SCREEN = new Set(["claude-code", "opencode", "grok"]); // agy: its chat UI unverified — found by the read
283
+ /** The full-screen CLIs whose earlier output the Log tab shows (it reads their transcript: log-service.ts). */
284
+ const LOG_HAS_HISTORY = new Set(["claude-code"]);
285
+ /**
286
+ * M-2267 option (2), the leader's safe default: where a full-screen CLI's earlier output is — its Log tab (or that the
287
+ * Log tab is off, and where to turn it on). Only for whom the Log tab is (a signed-in session on the full app; never a
288
+ * guest or a View-only reader: they get the plain words), and only for a CLI the Log tab reads.
289
+ */
290
+ function logLinkFor(name, backend, signedIn) {
291
+ if (!signedIn || !LOG_HAS_HISTORY.has(backend)) return null;
292
+ const on = typeof document !== "undefined" && document.body?.dataset?.logTab === "1";
293
+ return on ? { href: logPath(name), text: tn("earlierInLog") } : { href: sectionPath("web-dashboard"), text: tn("logTabOff") };
294
+ }
295
+ function Terminal({ name, lease, density, every = 800, backend = "", signedIn = false }) {
219
296
  const term = useRef(null), pre = useRef(null);
220
- const grid = useRef({ cols: 0, rows: 0, applied: 0, key: "" });
297
+ const grid = useRef({ cols: 0, rows: 0, applied: 0, key: "", follows: false });
221
298
  const [problem, setProblem] = useState(null);
299
+ const [, setWordsRev] = useState(0);
300
+ // "follow" | "loading" | "reading"; the screen's text when reading began, the newest read since; a note about history.
301
+ const [mode, setModeState] = useState("follow");
302
+ const modeRef = useRef("follow");
303
+ const setMode = (m) => { modeRef.current = m; setModeState(m); };
304
+ const [newBelow, setNewBelow] = useState(false);
305
+ const [historyNote, setHistoryNoteState] = useState(null);
306
+ // A CLI found to keep no scrollback (full screen): nothing is offered or read again for it; the note says why, briefly.
307
+ const [noScrollback, setNoScrollback] = useState(FULL_SCREEN.has(backend));
308
+ const noScrollbackRef = useRef(FULL_SCREEN.has(backend));
309
+ const logLink = logLinkFor(name, backend, signedIn);
310
+ const noteTimer = useRef(null);
311
+ const setHistoryNote = (note, brief = false) => {
312
+ lease.clear(noteTimer.current); noteTimer.current = null;
313
+ setHistoryNoteState(note);
314
+ if (note && brief) noteTimer.current = lease.timeout(() => setHistoryNoteState(null), NOTE_MS);
315
+ };
316
+ // `reading`: the text shown while reading (the scrollback, or the screen as it was when the person left the bottom);
317
+ // `latest`: the newest screen read; `history`: whether what is shown has the scrollback in it.
318
+ // `drawn` (#2969 review P3): the text actually on the page now — what reading starts from, never a newer read not drawn.
319
+ const screenAt = useRef({ reading: null, latest: null, history: false, drawn: null });
320
+ // #2969 review (Prism P2): when the person last moved the terminal themselves (wheel, touch, key, pointer).
321
+ const gestureAt = useRef(-Infinity);
322
+ const recentGesture = () => performance.now() - gestureAt.current < GESTURE_MS;
222
323
  // #1674: the text size as of this render. The pane refresh and the resize handlers below are set up once per mount
223
324
  // and keep the fit() of that render; reading the size here, not from that render's props, keeps them on the current
224
325
  // choice (they used to put the mount-time size back within one 800 ms refresh).
@@ -236,16 +337,71 @@ function Terminal({ name, lease, density, every = 800 }) {
236
337
  const pcs = getComputedStyle(p);
237
338
  const lh = parseFloat(pcs.lineHeight) / parseFloat(pcs.fontSize);
238
339
  const lineH = Number.isFinite(lh) && lh > 0 ? lh : 1.2;
239
- let size = Math.min(innerW / (g.cols * glyphRatio(p)), innerH / (g.rows * lineH)) * 0.995;
240
- if (density === "fit") size = Math.max(FIT_FLOOR_PX, Math.min(size, MAX_PX));
241
- else size = Math.max(8, Math.min(Math.max(MIN_PX, Math.min(size, MAX_PX)) * (VIEW_SCALE[density] || 1), 32));
340
+ const byW = innerW / (g.cols * glyphRatio(p)), byH = innerH / (g.rows * lineH);
341
+ const sizeFor = (limit) => (density === "fit" ? Math.max(FIT_FLOOR_PX, Math.min(limit * 0.995, MAX_PX))
342
+ : Math.max(8, Math.min(Math.max(MIN_PX, Math.min(limit * 0.995, MAX_PX)) * (VIEW_SCALE[density] || 1), 32)));
343
+ const size = sizeFor(g.follows ? byW : Math.min(byW, byH));
242
344
  g.key = key;
243
345
  // A small change is skipped only when growing (the size applied still fits); a smaller box always shrinks it.
244
346
  if (size >= g.applied && size - g.applied < 0.75) return;
245
- g.applied = size;
246
- p.style.fontSize = `${size.toFixed(2)}px`; // CSSOM, never a style attribute (#1300)
347
+ const padH = (parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom)) || 0;
348
+ const was = g.applied || parseFloat(pcs.fontSize) || 0, before = box.clientHeight - padH;
349
+ const apply = (px) => { g.applied = px; p.style.fontSize = `${px.toFixed(2)}px`; }; // CSSOM, never a style attribute (#1300)
350
+ apply(size);
351
+ // v2.3.4-beta.4 P1: the box's height is a limit only while it is the box's own. Read again right after the text took
352
+ // its new size (the read lays the page out), a box whose height moved with it, by the same factor, is following its
353
+ // text — a stale stylesheet, or any container that grows with its content: measured next time it would shrink the
354
+ // text 0.5% each refresh, for ever. Then the width alone decides, at once and from then on (until the choice or the
355
+ // stylesheet changes). Cause and effect within this one call: a resize between calls (#2991 review: the box made
356
+ // shorter step by step, where a height-limited size moves in proportion too) never counts. The move must match the
357
+ // expected one closely for its size (#2991 review r2: not ±5% of the whole), and a second probe in the same call —
358
+ // 10% smaller, then back, before any frame is painted — must move it in proportion again: a one-off fixed drop (a
359
+ // scrollbar coming) never does.
360
+ if (!g.follows && was > 0 && before > 0) {
361
+ const height = () => box.clientHeight - padH;
362
+ const moves = (from, to, ratio) => {
363
+ const expect = from * (ratio - 1), got = to - from;
364
+ return got !== 0 && Math.sign(got) === Math.sign(expect) && Math.abs(got - expect) <= Math.max(1.5, 0.25 * Math.abs(expect));
365
+ };
366
+ const after = height();
367
+ if (moves(before, after, size / was)) {
368
+ p.style.fontSize = `${(size * 0.9).toFixed(2)}px`;
369
+ const probed = height();
370
+ p.style.fontSize = `${size.toFixed(2)}px`;
371
+ if (moves(after, probed, 0.9)) {
372
+ g.follows = true;
373
+ const byWidth = sizeFor(byW);
374
+ if (byWidth !== size) apply(byWidth);
375
+ }
376
+ }
377
+ }
378
+ };
379
+ /** Keep a following view on the newest line (a grid taller than the box at the larger sizes scrolls). */
380
+ const toBottom = () => { const box = term.current; if (box) box.scrollTop = box.scrollHeight; };
381
+ const draw = (text) => {
382
+ pre.current.innerHTML = ansiToHtml(text); // escaped by construction: markup only from ansiToHtml's own spans
383
+ paintAnsi(pre.current);
384
+ screenAt.current.drawn = text;
247
385
  };
248
- useEffect(() => { grid.current.key = ""; fit(); }, [density]);
386
+ useEffect(() => { grid.current.key = ""; grid.current.follows = false; fit(); }, [density]);
387
+ // A tab opened before an upgrade: its stylesheet and the View's words read again (healStalePage), then fitted anew.
388
+ useEffect(() => {
389
+ const h = healStalePage(document, (u) => import(u), () => { setWordsRev((n) => n + 1); });
390
+ if (h.css) lease.timeout(() => { grid.current.key = ""; grid.current.follows = false; fit(); }, 300);
391
+ }, []);
392
+ const atBottom = () => { const box = term.current; return !box || box.scrollHeight - box.scrollTop - box.clientHeight <= 2; };
393
+ /** Reading what is shown now (`text`, with or without the scrollback): the refresh only says whether there is more. */
394
+ const startReading = (text, history) => {
395
+ screenAt.current.reading = text; screenAt.current.history = history;
396
+ setNewBelow(false); setMode("reading");
397
+ };
398
+ const grid1 = (r) => {
399
+ const c = Number(r.headers.get("X-Pane-Cols")), rw = Number(r.headers.get("X-Pane-Rows"));
400
+ if (c >= 1 && rw >= 1 && (c !== grid.current.cols || rw !== grid.current.rows)) { grid.current.cols = c; grid.current.rows = rw; grid.current.key = ""; }
401
+ };
402
+ // The person asked for what scrolled off: one bounded read, then reading (or why there is nothing to read).
403
+ const loadEarlier = useRef(null);
404
+ const follow = useRef(null);
249
405
  useEffect(() => {
250
406
  const reads = readStream(lease);
251
407
  const refresh = async () => {
@@ -263,15 +419,72 @@ function Terminal({ name, lease, density, every = 800 }) {
263
419
  if (!r.ok) { setProblem(r.status === 404 ? tn("paneUnavailable") : r.status === 403 && guestPage() ? tn("guestNeedsTrusted") : tn("paneFailed", { name, status: r.status })); return; }
264
420
  const text = await r.text();
265
421
  if (!reads.live(token) || !pre.current) return;
266
- const c = Number(r.headers.get("X-Pane-Cols")), rw = Number(r.headers.get("X-Pane-Rows"));
267
- if (c >= 1 && rw >= 1 && (c !== grid.current.cols || rw !== grid.current.rows)) { grid.current.cols = c; grid.current.rows = rw; grid.current.key = ""; }
268
422
  setProblem(null);
269
- pre.current.innerHTML = ansiToHtml(text); // escaped by construction: markup only from ansiToHtml's own spans
270
- paintAnsi(pre.current);
423
+ screenAt.current.latest = text;
424
+ // Reading earlier output: nothing is redrawn under the person — the read only says whether there is more.
425
+ if (modeRef.current !== "follow") { if (modeRef.current === "reading") setNewBelow(!sameScreen(screenAt.current.reading, text)); return; }
426
+ // #2969 review (Prism P2): the person is moving a screen taller than its box away from the bottom right now —
427
+ // this is them leaving it, not the latest to follow: read on, nothing redrawn.
428
+ // Reading starts from what is drawn; this newer read (not drawn) is then the new output below.
429
+ if (recentGesture() && !atBottom()) { startReading(screenAt.current.drawn, false); setNewBelow(!sameScreen(screenAt.current.drawn, text)); return; }
430
+ grid1(r);
431
+ draw(text);
271
432
  fit();
433
+ toBottom();
272
434
  } catch { /* a transient error keeps the last frame */ }
273
435
  finally { reads.end(token); }
274
436
  };
437
+ loadEarlier.current = async () => {
438
+ // From following, or from reading what was on the screen (the person reached its top): never twice, never over it.
439
+ const from = modeRef.current;
440
+ if (from !== "follow" && !(from === "reading" && !screenAt.current.history)) return;
441
+ if (noScrollbackRef.current) { setHistoryNote(tn("noEarlier"), true); return; } // known: nothing to read
442
+ const box0 = term.current;
443
+ // Where the person is, measured from the end: the screen's lines stay where they are under the scrollback added.
444
+ const fromEnd = box0 ? box0.scrollHeight - box0.scrollTop : 0;
445
+ const back = () => { setMode(from); if (from === "follow") toBottom(); };
446
+ setMode("loading"); setHistoryNote(tn("loadingEarlier"));
447
+ try {
448
+ const r = await lease.fetch(`/api/pane/${encodeURIComponent(name)}?history=1`);
449
+ if (!lease.current() || modeRef.current !== "loading") return;
450
+ // #2969 review (Prism P2): a scrollback read that failed — or answered without its facts — changes nothing shown.
451
+ if (!r.ok || r.headers.get("X-Pane-History-Lines") == null) { back(); setHistoryNote(r.status === 429 ? null : tn("earlierFailed"), true); return; }
452
+ const text = await r.text();
453
+ if (!lease.current() || modeRef.current !== "loading" || !pre.current) return;
454
+ // A full-screen CLI (the alternate screen, or one that redraws its whole window) leaves nothing above its screen;
455
+ // any other with no history yet has simply not scrolled: say which, keep following.
456
+ if (r.headers.get("X-Pane-Alternate") === "1" || REDRAWS_IN_PLACE.has(backend)) {
457
+ noScrollbackRef.current = true; setNoScrollback(true);
458
+ back(); setHistoryNote(tn("noEarlier"), true); return;
459
+ }
460
+ if (r.headers.get("X-Pane-History-Lines") === "0") { back(); setHistoryNote(tn("nothingEarlier"), true); return; }
461
+ grid1(r);
462
+ const lines = text.split("\n").length, rows = grid.current.rows || 0;
463
+ // #2969 review (Prism P3): new output is judged against what is shown — the scrollback's own last screen — and
464
+ // a screen read while this one was out already counts.
465
+ screenAt.current.reading = text; screenAt.current.history = true;
466
+ setNewBelow(screenAt.current.latest != null && !sameScreen(text, screenAt.current.latest));
467
+ setMode("reading"); setHistoryNote(null);
468
+ draw(text);
469
+ fit();
470
+ const box = term.current;
471
+ if (box) {
472
+ const lineH = lines > 0 ? pre.current.scrollHeight / lines : 16;
473
+ // From following (at the bottom): a few lines up — the earlier output right above. From reading: the same lines.
474
+ box.scrollTop = from === "follow"
475
+ ? Math.max(0, box.scrollHeight - box.clientHeight - lineH * Math.min(3, Math.max(1, lines - rows)))
476
+ : Math.max(0, box.scrollHeight - fromEnd);
477
+ }
478
+ } catch { if (lease.current()) { back(); setHistoryNote(tn("earlierFailed"), true); } }
479
+ };
480
+ follow.current = () => {
481
+ if (modeRef.current === "follow") return;
482
+ setMode("follow"); setNewBelow(false); setHistoryNote(null); screenAt.current.history = false;
483
+ const latest = screenAt.current.latest;
484
+ if (latest != null && pre.current) { draw(latest); fit(); }
485
+ toBottom();
486
+ refresh();
487
+ };
275
488
  refresh();
276
489
  lease.interval(refresh, every);
277
490
  let timer = null;
@@ -282,8 +495,44 @@ function Terminal({ name, lease, density, every = 800 }) {
282
495
  ro.observe(term.current);
283
496
  lease.hold(() => ro.disconnect());
284
497
  }
498
+ // Scrolling up asks for the earlier output; reaching the bottom while reading follows again.
499
+ const box = term.current;
500
+ if (box) {
501
+ // Asking for the earlier output: from following, or from reading the screen as it was — at the top.
502
+ const mayLoad = () => (modeRef.current === "follow" || (modeRef.current === "reading" && !screenAt.current.history)) && box.scrollTop <= 0;
503
+ const gesture = () => { gestureAt.current = performance.now(); };
504
+ lease.on(box, "wheel", (e) => { gesture(); if (e.deltaY < 0 && mayLoad()) loadEarlier.current(); }, { passive: true });
505
+ let touchY = null;
506
+ lease.on(box, "touchstart", (e) => { gesture(); touchY = e.touches && e.touches[0] ? e.touches[0].clientY : null; }, { passive: true });
507
+ lease.on(box, "touchmove", (e) => {
508
+ gesture();
509
+ const y = e.touches && e.touches[0] ? e.touches[0].clientY : null;
510
+ if (touchY == null || y == null) return;
511
+ if (y - touchY > 24 && mayLoad()) { touchY = null; loadEarlier.current(); }
512
+ }, { passive: true });
513
+ lease.on(box, "pointerdown", gesture, { passive: true }); // the scrollbar, dragged
514
+ lease.on(box, "keydown", (e) => {
515
+ gesture();
516
+ if ((e.key === "PageUp" || e.key === "ArrowUp" || e.key === "Home") && mayLoad()) { e.preventDefault(); loadEarlier.current(); }
517
+ else if (modeRef.current === "reading" && e.key === "End") { e.preventDefault(); follow.current(); }
518
+ });
519
+ lease.on(box, "scroll", () => {
520
+ if (modeRef.current === "reading") { if (atBottom()) follow.current(); return; }
521
+ // #2969 review (Prism P2): a screen taller than its box (a larger Aa), moved up from the bottom by the person:
522
+ // reading what is on it — the refresh no longer pulls them back down.
523
+ if (modeRef.current === "follow" && recentGesture() && !atBottom()) startReading(screenAt.current.drawn, false);
524
+ }, { passive: true });
525
+ }
285
526
  }, [lease, name]);
286
- return html`<div data-trust="panel-content" class="v-term" ref=${term}>${problem ? html`<p class="v-term-note" role="status">${problem}</p>` : null}<pre ref=${pre} class="v-pre"></pre></div>`;
527
+ const reading = mode === "reading";
528
+ // A full-screen CLI's Log link is a bar above the terminal, not over it: its first row is never covered.
529
+ return html`<div class="v-term-box">
530
+ ${noScrollback && logLink ? html`<p class="v-term-logbar"><a class="v-term-log" href=${logLink.href}>${logLink.text}<${Icon} name="forward" size=${14} /></a></p>` : null}
531
+ <div data-trust="panel-content" class=${`v-term${reading ? " is-reading" : ""}`} ref=${term} tabindex="0" aria-label=${tn(reading ? "readingEarlier" : "earlierHint")}>${problem ? html`<p class="v-term-note" role="status">${problem}</p>` : null}<pre ref=${pre} class="v-pre"></pre></div>
532
+ ${historyNote ? html`<p class="v-term-history-note" role="status">${historyNote}</p>` : null}
533
+ ${mode === "follow" && !noScrollback ? html`<button type="button" class="v-term-earlier btn btn-sm" title=${tn("earlierHint")} onClick=${() => loadEarlier.current && loadEarlier.current()}><${Icon} name="up" size=${14} />${tn("earlier")}</button>` : null}
534
+ ${reading ? html`<button type="button" class=${`v-term-latest btn btn-sm${newBelow ? " has-new" : ""}`} onClick=${() => follow.current && follow.current()}>${newBelow ? html`<span class="v-term-new">${tn("newBelow")} · </span>` : null}${tn("jumpLatest")}<${Icon} name="down" size=${14} /></button>` : null}
535
+ </div>`;
287
536
  }
288
537
 
289
538
  // The advance of one monospace cell as a fraction of font-size, measured once on this page.